car-verify 0.52.0

Static plan verification for Agent IR
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
//! Temporal policies over an execution **trace**, not a plan.
//!
//! CAR could express "never call this tool" but not "never call this tool
//! before that one succeeded in this run". The two existing mechanisms each
//! stop short:
//!
//! * `car-policy` rules (`deny_tool`, `deny_keyword`, `deny_tool_param`) are
//!   stateless single-action predicates — each is evaluated against one action
//!   in isolation, and nothing consults what has already happened.
//! * [`crate::workflow_graph::TemporalPolicy`] has exactly one operator,
//!   `Precedes`, and it is checked over the workflow **graph** before execution.
//!   It reads no event log, and it does not apply to the assistant loop at all
//!   — only to declared `car-workflow` stage graphs.
//!
//! So CAR statically checked properties of the plan and enforced stateless predicates on
//! each action, but never checked that *this particular run* satisfied an
//! ordering constraint (Parslee-ai/car#704).
//!
//! # Deterministic and judge-free
//!
//! Everything here is pure functions over a trace. That is the point: it is the
//! rare quality mechanism that needs no model in the loop, which matches the
//! runtime's posture — the same reason `car-verify`'s existing checks are graph
//! algorithms rather than prompts. One rule set drives two uses:
//!
//! * [`evaluate_trace`] scores a **completed** run, e.g. from a `car-eventlog`
//!   journal, for compliance after the fact;
//! * [`check_next`] checks a **prefix** online, before a call executes, so a
//!   violating call can be blocked rather than reported afterwards. That is what
//!   makes this enforcement rather than reporting.
//!
//! The two must agree, and the asymmetry between them is deliberate: a liveness
//! rule ([`TraceRule::Eventually`]) can only be violated by a run that *ends*
//! without the obligation discharged, so a prefix can never violate it. Treating
//! it as violated online would block a call for something the run might still do.

use serde::{Deserialize, Serialize};
use std::collections::HashSet;

/// One observed step of an execution trace.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TraceStep {
    /// The tool that ran.
    pub tool: String,
    /// Whether it succeeded. Load-bearing: "deploy may not run unless `test`
    /// **succeeded** earlier" is a different rule from "unless `test` ran", and
    /// the weaker reading is satisfied by a test that ran and failed.
    pub ok: bool,
}

impl TraceStep {
    pub fn ok(tool: impl Into<String>) -> Self {
        Self {
            tool: tool.into(),
            ok: true,
        }
    }
    pub fn failed(tool: impl Into<String>) -> Self {
        Self {
            tool: tool.into(),
            ok: false,
        }
    }
}

/// A temporal rule over a trace prefix.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TraceRule {
    /// `later` may not run unless `earlier` **succeeded** earlier in this run.
    /// ("`deploy` may not run unless `test` succeeded.")
    Precedes {
        earlier: String,
        later: String,
        #[serde(default)]
        name: Option<String>,
    },
    /// `tool` may never run in this run. The trace-level counterpart of
    /// `deny_tool`, included so one spec file can express a whole procedure.
    Never {
        tool: String,
        #[serde(default)]
        name: Option<String>,
    },
    /// Once `trigger` runs, `required` must run before the trace ends.
    /// ("Once `acquire_lease` is called, `release_lease` must follow.")
    ///
    /// A **liveness** rule: only a completed trace can violate it.
    Eventually {
        trigger: String,
        required: String,
        #[serde(default)]
        name: Option<String>,
    },
    /// After `start` runs, `forbidden` may not run until `release` runs.
    /// ("`write_file` must never follow `fetch_url` without an intervening
    /// approval.")
    Until {
        start: String,
        forbidden: String,
        release: String,
        #[serde(default)]
        name: Option<String>,
    },
}

impl TraceRule {
    /// The rule's label, or a derived one so a violation is always attributable.
    pub fn label(&self) -> String {
        match self {
            Self::Precedes {
                earlier,
                later,
                name,
            } => name
                .clone()
                .unwrap_or_else(|| format!("{earlier}_precedes_{later}")),
            Self::Never { tool, name } => name.clone().unwrap_or_else(|| format!("never_{tool}")),
            Self::Eventually {
                trigger,
                required,
                name,
            } => name
                .clone()
                .unwrap_or_else(|| format!("{trigger}_eventually_{required}")),
            Self::Until {
                start,
                forbidden,
                release,
                name,
            } => name
                .clone()
                .unwrap_or_else(|| format!("{forbidden}_forbidden_between_{start}_and_{release}")),
        }
    }
}

/// A rule violated by a trace or by the call about to run.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TraceViolation {
    pub rule: String,
    /// The tool whose execution violates (or would violate) the rule.
    pub tool: String,
    /// Index into the trace, or the prefix length for an online check — i.e.
    /// the position the offending call occupies.
    pub step: usize,
    pub explanation: String,
}

/// The result of scoring a completed trace.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TraceReport {
    pub compliant: bool,
    pub violations: Vec<TraceViolation>,
    /// Steps examined, so a caller can tell "compliant" from "nothing ran".
    pub steps: usize,
}

impl TraceReport {
    /// What kind of check produced these violations — see
    /// [`crate::EvidenceTier`].
    ///
    /// Each rule is a bounded temporal predicate over a finite, fully-supplied
    /// sequence of steps, evaluated by scanning it: `Precedes` and `Until` are
    /// safety properties decided at the offending index, `Never` is membership,
    /// `Eventually` is discharge-by-end-of-trace. No search, no abstraction, no
    /// threshold — this is the crate's one *runtime* verifier, and it is as
    /// decided as the static ones.
    ///
    /// It decides compliance of **the trace it was given**. A run whose events
    /// never reached the log is not a compliant run; it is an unexamined one,
    /// which is what [`Self::steps`] is there to expose.
    pub const fn evidence_tier(&self) -> crate::EvidenceTier {
        crate::EvidenceTier::DecisionProcedure
    }
}

/// Whether `prefix` has a successful run of `tool`.
fn succeeded_in(prefix: &[TraceStep], tool: &str) -> bool {
    prefix.iter().any(|s| s.tool == tool && s.ok)
}

/// Whether `tool` appears at all (successfully or not).
fn appears_in(prefix: &[TraceStep], tool: &str) -> bool {
    prefix.iter().any(|s| s.tool == tool)
}

/// Violations that running `next` would introduce, given everything already
/// executed in `prefix`.
///
/// This is the online gate: call it *before* dispatching, and a non-empty result
/// means the call breaks the procedure. Only safety rules can be decided from a
/// prefix — see the module docs on why `Eventually` is deliberately absent here.
pub fn check_next(rules: &[TraceRule], prefix: &[TraceStep], next: &str) -> Vec<TraceViolation> {
    let step = prefix.len();
    let mut out = Vec::new();

    for rule in rules {
        match rule {
            TraceRule::Precedes { earlier, later, .. }
                if next == later && !succeeded_in(prefix, earlier) =>
            {
                let ran_but_failed = appears_in(prefix, earlier);
                out.push(TraceViolation {
                    rule: rule.label(),
                    tool: next.to_string(),
                    step,
                    explanation: if ran_but_failed {
                        format!("`{next}` requires `{earlier}` to have succeeded first; it ran and failed")
                    } else {
                        format!("`{next}` requires `{earlier}` to have succeeded first; it has not run")
                    },
                });
            }
            TraceRule::Never { tool, .. } if next == tool => {
                out.push(TraceViolation {
                    rule: rule.label(),
                    tool: next.to_string(),
                    step,
                    explanation: format!("`{next}` may never run"),
                });
            }
            TraceRule::Until {
                start,
                forbidden,
                release,
                ..
            } if next == forbidden && in_forbidden_window(prefix, start, release) => {
                out.push(TraceViolation {
                    rule: rule.label(),
                    tool: next.to_string(),
                    step,
                    explanation: format!(
                        "`{next}` may not run after `{start}` until `{release}` has run"
                    ),
                });
            }
            // `Eventually` is liveness: a prefix cannot violate it, because the
            // run may still discharge the obligation. Judged by `evaluate_trace`.
            _ => {}
        }
    }
    out
}

/// Whether the prefix currently sits inside a `start`…`release` window: `start`
/// has run more recently than any `release`.
fn in_forbidden_window(prefix: &[TraceStep], start: &str, release: &str) -> bool {
    let last = |tool: &str| prefix.iter().rposition(|s| s.tool == tool && s.ok);
    match (last(start), last(release)) {
        (Some(s), Some(r)) => s > r,
        (Some(_), None) => true,
        (None, _) => false,
    }
}

/// Score a **completed** trace against the rules.
///
/// Replays the trace through the same online check so the two can never
/// disagree about a safety rule, then adds the liveness rules that only a
/// finished run can settle.
pub fn evaluate_trace(rules: &[TraceRule], trace: &[TraceStep]) -> TraceReport {
    let mut violations = Vec::new();

    for (i, step) in trace.iter().enumerate() {
        violations.extend(check_next(rules, &trace[..i], &step.tool));
    }

    // Liveness: an obligation triggered but never discharged.
    for rule in rules {
        if let TraceRule::Eventually {
            trigger, required, ..
        } = rule
        {
            let triggered_at: Vec<usize> = trace
                .iter()
                .enumerate()
                .filter(|(_, s)| &s.tool == trigger && s.ok)
                .map(|(i, _)| i)
                .collect();
            for at in triggered_at {
                let discharged = trace[at + 1..].iter().any(|s| &s.tool == required && s.ok);
                if !discharged {
                    violations.push(TraceViolation {
                        rule: rule.label(),
                        tool: trigger.clone(),
                        step: at,
                        explanation: format!(
                            "`{trigger}` ran at step {at} but `{required}` never followed"
                        ),
                    });
                }
            }
        }
    }

    TraceReport {
        compliant: violations.is_empty(),
        steps: trace.len(),
        violations,
    }
}

/// Build a trace from `car-eventlog` JSONL records.
///
/// Takes parsed JSON rather than `car_eventlog::Event` deliberately: `car-verify`
/// depends only on `car-ir` and serde, and making the verifier depend on the
/// logger would invert the layering for no gain — the journal is already JSON,
/// and one field access is cheaper than a crate edge.
///
/// Only `action_succeeded` / `action_failed` become steps; every other kind is
/// bookkeeping about a call rather than the call itself, and counting
/// `action_executing` too would double every step. The tool name is read from
/// `data.tool`, falling back to `action_id` — the coder journals a tool call
/// under `data.tool` and its checks under an `action_id` of `check:<name>`.
pub fn trace_from_events(events: &[serde_json::Value]) -> Vec<TraceStep> {
    events
        .iter()
        .filter_map(|e| {
            let kind = e.get("kind")?.as_str()?;
            let ok = match kind {
                "action_succeeded" => true,
                "action_failed" => false,
                _ => return None,
            };
            let tool = e
                .get("data")
                .and_then(|d| d.get("tool"))
                .and_then(|t| t.as_str())
                .or_else(|| e.get("action_id").and_then(|a| a.as_str()))?
                .to_string();
            Some(TraceStep { tool, ok })
        })
        .collect()
}

/// Tools named by any rule — the set a dispatcher needs to watch, so a runtime
/// can skip the check entirely for calls no rule mentions.
pub fn governed_tools(rules: &[TraceRule]) -> HashSet<String> {
    let mut out = HashSet::new();
    for rule in rules {
        match rule {
            TraceRule::Precedes { earlier, later, .. } => {
                out.insert(earlier.clone());
                out.insert(later.clone());
            }
            TraceRule::Never { tool, .. } => {
                out.insert(tool.clone());
            }
            TraceRule::Eventually {
                trigger, required, ..
            } => {
                out.insert(trigger.clone());
                out.insert(required.clone());
            }
            TraceRule::Until {
                start,
                forbidden,
                release,
                ..
            } => {
                out.insert(start.clone());
                out.insert(forbidden.clone());
                out.insert(release.clone());
            }
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    fn precedes(earlier: &str, later: &str) -> TraceRule {
        TraceRule::Precedes {
            earlier: earlier.into(),
            later: later.into(),
            name: None,
        }
    }

    // --- Precedes: "deploy may not run unless test succeeded" -------------

    #[test]
    fn deploy_without_test_is_blocked_online_and_offline() {
        let rules = [precedes("test", "deploy")];
        let prefix = [TraceStep::ok("build")];
        let online = check_next(&rules, &prefix, "deploy");
        assert_eq!(online.len(), 1, "{online:?}");
        assert!(online[0].explanation.contains("has not run"));

        let report = evaluate_trace(&rules, &[TraceStep::ok("build"), TraceStep::ok("deploy")]);
        assert!(!report.compliant);
        assert_eq!(report.violations.len(), 1);
    }

    #[test]
    fn deploy_after_a_successful_test_is_allowed() {
        let rules = [precedes("test", "deploy")];
        let trace = [TraceStep::ok("test"), TraceStep::ok("deploy")];
        assert!(check_next(&rules, &trace[..1], "deploy").is_empty());
        assert!(evaluate_trace(&rules, &trace).compliant);
    }

    /// The distinction the `ok` flag exists for: a test that RAN is not a test
    /// that PASSED, and the weaker reading would authorize the deploy.
    #[test]
    fn a_failed_test_does_not_authorize_the_deploy() {
        let rules = [precedes("test", "deploy")];
        let prefix = [TraceStep::failed("test")];
        let v = check_next(&rules, &prefix, "deploy");
        assert_eq!(v.len(), 1);
        assert!(
            v[0].explanation.contains("ran and failed"),
            "the message must distinguish this from never having run: {:?}",
            v[0].explanation
        );
    }

    #[test]
    fn an_unrelated_tool_is_never_blocked() {
        let rules = [precedes("test", "deploy")];
        assert!(check_next(&rules, &[], "format").is_empty());
    }

    // --- Never ------------------------------------------------------------

    #[test]
    fn never_blocks_on_the_first_call() {
        let rules = [TraceRule::Never {
            tool: "rm_rf".into(),
            name: None,
        }];
        assert_eq!(check_next(&rules, &[], "rm_rf").len(), 1);
        assert!(!evaluate_trace(&rules, &[TraceStep::ok("rm_rf")]).compliant);
    }

    // --- Eventually: liveness ---------------------------------------------

    #[test]
    fn an_undischarged_obligation_fails_the_completed_trace() {
        let rules = [TraceRule::Eventually {
            trigger: "acquire_lease".into(),
            required: "release_lease".into(),
            name: None,
        }];
        let report = evaluate_trace(&rules, &[TraceStep::ok("acquire_lease")]);
        assert!(!report.compliant);
        assert!(report.violations[0].explanation.contains("never followed"));
    }

    #[test]
    fn a_discharged_obligation_passes() {
        let rules = [TraceRule::Eventually {
            trigger: "acquire_lease".into(),
            required: "release_lease".into(),
            name: None,
        }];
        let trace = [
            TraceStep::ok("acquire_lease"),
            TraceStep::ok("write_file"),
            TraceStep::ok("release_lease"),
        ];
        assert!(evaluate_trace(&rules, &trace).compliant);
    }

    /// The deliberate asymmetry: a prefix can never violate liveness, or the
    /// gate would block a call for something the run might still do.
    #[test]
    fn liveness_never_blocks_a_prefix() {
        let rules = [TraceRule::Eventually {
            trigger: "acquire_lease".into(),
            required: "release_lease".into(),
            name: None,
        }];
        let prefix = [TraceStep::ok("acquire_lease")];
        assert!(check_next(&rules, &prefix, "anything").is_empty());
    }

    /// Each trigger carries its own obligation — a single release does not
    /// discharge a lease acquired after it.
    #[test]
    fn a_second_trigger_needs_its_own_discharge() {
        let rules = [TraceRule::Eventually {
            trigger: "acquire_lease".into(),
            required: "release_lease".into(),
            name: None,
        }];
        let trace = [
            TraceStep::ok("acquire_lease"),
            TraceStep::ok("release_lease"),
            TraceStep::ok("acquire_lease"),
        ];
        let report = evaluate_trace(&rules, &trace);
        assert!(!report.compliant);
        assert_eq!(report.violations[0].step, 2);
    }

    // --- Until: scoped prohibition ----------------------------------------

    fn until_rule() -> TraceRule {
        TraceRule::Until {
            start: "fetch_url".into(),
            forbidden: "write_file".into(),
            release: "approval".into(),
            name: None,
        }
    }

    #[test]
    fn the_window_opens_at_start_and_closes_at_release() {
        let rules = [until_rule()];
        // Before `fetch_url` the write is fine.
        assert!(check_next(&rules, &[], "write_file").is_empty());
        // Inside the window it is not.
        let inside = [TraceStep::ok("fetch_url")];
        assert_eq!(check_next(&rules, &inside, "write_file").len(), 1);
        // After the release it is fine again.
        let released = [TraceStep::ok("fetch_url"), TraceStep::ok("approval")];
        assert!(check_next(&rules, &released, "write_file").is_empty());
    }

    /// A second `fetch_url` after an approval re-opens the window — otherwise
    /// one approval would authorize every later fetch for the rest of the run.
    #[test]
    fn a_later_start_reopens_the_window() {
        let rules = [until_rule()];
        let prefix = [
            TraceStep::ok("fetch_url"),
            TraceStep::ok("approval"),
            TraceStep::ok("fetch_url"),
        ];
        assert_eq!(check_next(&rules, &prefix, "write_file").len(), 1);
    }

    /// A failed approval does not close the window.
    #[test]
    fn a_failed_release_does_not_close_the_window() {
        let rules = [until_rule()];
        let prefix = [TraceStep::ok("fetch_url"), TraceStep::failed("approval")];
        assert_eq!(check_next(&rules, &prefix, "write_file").len(), 1);
    }

    // --- Agreement between the two entry points ---------------------------

    /// Offline scoring replays the online check, so a safety rule can never be
    /// enforced one way live and scored another way afterwards.
    #[test]
    fn offline_scoring_agrees_with_the_online_gate() {
        let rules = [precedes("test", "deploy"), until_rule()];
        let trace = [
            TraceStep::ok("fetch_url"),
            TraceStep::ok("write_file"), // violates Until
            TraceStep::ok("deploy"),     // violates Precedes
        ];
        let report = evaluate_trace(&rules, &trace);
        assert_eq!(report.violations.len(), 2, "{report:?}");
        assert_eq!(report.steps, 3);

        // The same two calls are refused online, at the same positions.
        assert_eq!(check_next(&rules, &trace[..1], "write_file").len(), 1);
        assert_eq!(check_next(&rules, &trace[..2], "deploy").len(), 1);
    }

    #[test]
    fn an_empty_rule_set_is_vacuously_compliant() {
        let report = evaluate_trace(&[], &[TraceStep::ok("anything")]);
        assert!(report.compliant);
        assert_eq!(report.steps, 1);
    }

    #[test]
    fn governed_tools_lists_every_tool_a_rule_mentions() {
        let rules = [precedes("test", "deploy"), until_rule()];
        let tools = governed_tools(&rules);
        for expected in ["test", "deploy", "fetch_url", "write_file", "approval"] {
            assert!(
                tools.contains(expected),
                "{expected} missing from {tools:?}"
            );
        }
    }

    #[test]
    fn a_trace_is_built_from_event_log_records() {
        let events: Vec<serde_json::Value> = vec![
            serde_json::json!({"kind": "action_executing", "data": {"tool": "test"}}),
            serde_json::json!({"kind": "action_succeeded", "data": {"tool": "test"}}),
            serde_json::json!({"kind": "action_failed", "data": {"tool": "deploy"}}),
            serde_json::json!({"kind": "state_changed", "data": {}}),
            serde_json::json!({"kind": "action_succeeded", "action_id": "check:lint"}),
        ];
        let trace = trace_from_events(&events);
        assert_eq!(
            trace,
            vec![
                TraceStep::ok("test"),
                TraceStep::failed("deploy"),
                TraceStep::ok("check:lint"),
            ],
            "only executed calls become steps, and `action_executing` must not \
             double-count the call its `action_succeeded` already records"
        );
    }

    /// End-to-end for the acceptance criterion: a rule is authored, a run is
    /// replayed from its journal, and the violation is detected.
    #[test]
    fn a_violating_run_is_detected_from_its_event_log() {
        let rules = [precedes("test", "deploy")];
        let events: Vec<serde_json::Value> = vec![
            serde_json::json!({"kind": "action_succeeded", "data": {"tool": "build"}}),
            serde_json::json!({"kind": "action_succeeded", "data": {"tool": "deploy"}}),
        ];
        let report = evaluate_trace(&rules, &trace_from_events(&events));
        assert!(!report.compliant);
        assert_eq!(report.violations[0].tool, "deploy");
    }

    #[test]
    fn rules_round_trip_through_json() {
        let rules = vec![precedes("test", "deploy"), until_rule()];
        let json = serde_json::to_string(&rules).unwrap();
        let back: Vec<TraceRule> = serde_json::from_str(&json).unwrap();
        assert_eq!(rules, back);
    }
}

/// A live gate: accumulates the run's trace and refuses a call that would break
/// the procedure.
///
/// This is the piece that makes trace rules *enforcement* rather than reporting.
/// `car-policy`'s three rule kinds are stateless, so they can be registered as
/// `PolicyCheck`s that see one action in isolation; a temporal rule cannot —
/// it needs the prefix. Hence a gate object the dispatcher owns for the life of
/// the run.
///
/// Empty rules make every method a no-op, which is the overwhelmingly common
/// case: a project with no `trace_rule` entries behaves exactly as before.
#[derive(Debug, Clone, Default)]
pub struct TraceGate {
    rules: Vec<TraceRule>,
    trace: Vec<TraceStep>,
    governed: HashSet<String>,
}

impl TraceGate {
    pub fn new(rules: Vec<TraceRule>) -> Self {
        let governed = governed_tools(&rules);
        Self {
            rules,
            trace: Vec::new(),
            governed,
        }
    }

    /// True when no rule was authored — callers can skip the gate entirely.
    pub fn is_empty(&self) -> bool {
        self.rules.is_empty()
    }

    /// Check a call **before** dispatching it. Empty result = allowed.
    ///
    /// Short-circuits on tools no rule mentions, so the cost on an ungoverned
    /// call is one hash lookup.
    pub fn check(&self, tool: &str) -> Vec<TraceViolation> {
        if self.rules.is_empty() || !self.governed.contains(tool) {
            return Vec::new();
        }
        check_next(&self.rules, &self.trace, tool)
    }

    /// Record that `tool` ran. Call this for every executed tool, including
    /// ungoverned ones: a rule added later in the session is not the concern,
    /// but `Until` windows and `Precedes` both depend on ordering, and a trace
    /// with holes in it silently answers the wrong question.
    pub fn record(&mut self, tool: &str, ok: bool) {
        self.trace.push(TraceStep {
            tool: tool.to_string(),
            ok,
        });
    }

    /// Score the completed run, including the liveness rules a prefix cannot
    /// settle. Call at the end of a run.
    pub fn finish(&self) -> TraceReport {
        evaluate_trace(&self.rules, &self.trace)
    }

    /// The trace accumulated so far.
    pub fn trace(&self) -> &[TraceStep] {
        &self.trace
    }
}

#[cfg(test)]
mod gate_tests {
    use super::*;

    fn rules() -> Vec<TraceRule> {
        vec![
            TraceRule::Precedes {
                earlier: "test".into(),
                later: "deploy".into(),
                name: None,
            },
            TraceRule::Eventually {
                trigger: "acquire_lease".into(),
                required: "release_lease".into(),
                name: None,
            },
        ]
    }

    #[test]
    fn an_empty_gate_allows_everything() {
        let gate = TraceGate::default();
        assert!(gate.is_empty());
        assert!(gate.check("anything").is_empty());
        assert!(gate.finish().compliant);
    }

    #[test]
    fn the_gate_refuses_then_allows_as_the_run_progresses() {
        let mut gate = TraceGate::new(rules());
        assert_eq!(gate.check("deploy").len(), 1, "no test has run yet");

        gate.record("test", true);
        assert!(gate.check("deploy").is_empty(), "the precondition is met");
    }

    /// A failed run of the precondition must not unlock the gated call.
    #[test]
    fn a_failed_precondition_keeps_the_gate_shut() {
        let mut gate = TraceGate::new(rules());
        gate.record("test", false);
        assert_eq!(gate.check("deploy").len(), 1);
    }

    /// Ungoverned tools short-circuit, and are still recorded — ordering
    /// matters for `Until`, so a trace with holes answers the wrong question.
    #[test]
    fn ungoverned_tools_are_allowed_but_still_recorded() {
        let mut gate = TraceGate::new(rules());
        assert!(gate.check("read_file").is_empty());
        gate.record("read_file", true);
        assert_eq!(gate.trace().len(), 1);
    }

    /// Liveness is settled only at the end, never by the gate mid-run.
    #[test]
    fn an_undischarged_obligation_surfaces_only_at_finish() {
        let mut gate = TraceGate::new(rules());
        gate.record("acquire_lease", true);
        assert!(
            gate.check("anything").is_empty(),
            "a prefix must not block on liveness"
        );
        let report = gate.finish();
        assert!(!report.compliant, "the completed run is non-compliant");

        let mut gate = TraceGate::new(rules());
        gate.record("acquire_lease", true);
        gate.record("release_lease", true);
        assert!(gate.finish().compliant);
    }
}