openlatch-client 0.3.3

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
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
769
770
771
772
773
774
775
776
777
//! The two-stage bundle parse — plan 02 §2b, R14.
//!
//! ```text
//! STAGE 1  the typed envelope, with serde.  A failure REJECTS the bundle.
//! STAGE 2  each artifacts[i].body, by `kind`, into its own $def.  A failure
//!          skips exactly ONE item into skipped[] and THE REST ACTIVATES.
//! ```
//!
//! This is the schema rule made code. It is why the per-kind bodies are separate
//! `$defs` rather than an `if`/`then` discriminator: `build.rs` strips
//! conditionals before typify, so the discriminator would silently not exist.
//!
//! **Never reject a whole bundle for a stage-2 failure.** One malformed artifact
//! must not disarm every other rule on the host — the acceptance criterion is
//! *"skip that item into `skipped[]`, activate the rest, and log one OL-12xx line
//! per skipped item."*
//!
//! **The engine does not log.** It has no tracing dependency and OL-12xx codes
//! live in `src/core/error.rs`. It RETURNS [`Bundle::skipped`]; the caller logs.
//!
//! The precedent this follows rather than reinvents is
//! `crate::core::policy::parse_bundle_tolerant`, which already does the
//! retain-and-skip dance for schema-1 rules. Same shape, one level deeper.

use serde::Deserialize as _;

use crate::generated::types::{
    BundleFact, BundleFactRef, DirectiveTemplate, Exception, PolicyArtifact, PolicyBundle,
    PolicyBundleEffectClasses, PolicyBundleMeta, PolicyMode, T1PredicateTree, T2RegisterProgram,
    T3Hold,
};

use super::tier1::ScanTable;
use super::types::{
    SkippedItem, StateLayout, MODE_ENFORCE, MODE_MONITOR, SKIP_BAD_PATTERN, SKIP_BELOW_FLOOR,
    SKIP_BODY_PARSE_ERROR, SKIP_FLOAT_PRESENT, SKIP_UNKNOWN_KIND,
};

/// The bundle capability level this engine implements, compared against
/// `client_floor.min_client_version` and each artifact's `min_client_version`.
///
/// **This is not [`ENGINE_VERSION`](super::ENGINE_VERSION) and not the crate
/// version.** It is the schemas line the evaluator speaks — bundle schema 2 —
/// and it moves when the engine learns a new bundle capability, not when a
/// verdict changes or when the CLI ships a fix.
pub const SUPPORTED_BUNDLE_CLIENT_VERSION: &str = "2.0.0";

/// The four `kind`s the evaluator can read.
///
/// `ArtifactKind` is an OPEN string whose known values also include `command` and
/// `request` — those are schema-1 *rule* kinds and live on `rules[]`, not on
/// `artifacts[]`. An artifact carrying one is `unknown_kind` here, skipped, and
/// the rest of the bundle stays active.
pub const KNOWN_KINDS: &[&str] = &[
    KIND_T1_PREDICATE_TREE,
    KIND_T2_REGISTER_PROGRAM,
    KIND_T3_HOLD,
    KIND_EXCEPTION,
];

pub const KIND_T1_PREDICATE_TREE: &str = "t1_predicate_tree";
pub const KIND_T2_REGISTER_PROGRAM: &str = "t2_register_program";
pub const KIND_T3_HOLD: &str = "t3_hold";
pub const KIND_EXCEPTION: &str = "exception";

/// The D14 verdict read-aliases, and there are exactly **two**.
///
/// The PRD freezes `Verdict` at `allow | ask | block | optimize` and accepts
/// `approve` → `ask` and `deny` → `block` as read-aliases *for one release*. That
/// is the whole set: there is no alias for `allow` or `optimize`, and adding a
/// third here is a contract change, not a convenience.
///
/// The oracle gets these for free because it reads untyped JSON
/// (`evaluate.py::normalise_verdict`). We parse into the frozen enum, which is
/// the better trade — a verdict the client cannot interpret is not a value to
/// tolerate — but it moves the aliasing somewhere explicit, and this is that
/// place.
///
/// **Read only, never written.** [`Verdict`](crate::generated::types::Verdict) is
/// frozen to four values by schemas 2.0 and the platform depends on that, so the
/// enum is NOT widened and a `Decision` carries only the canonical four. Emitting
/// an alias would diverge the corpus comparison from the platform's stored
/// verdicts.
pub const VERDICT_READ_ALIASES: &[(&str, &str)] = &[("approve", "ask"), ("deny", "block")];

/// Every body key whose value is a `Verdict`.
///
/// All six Verdict-typed locations in `schemas/policy-bundle.schema.json`'s four
/// body `$defs` are **top-level properties** of their body — `t1.verdict`,
/// `t2.verdict`, `t3_hold.{on_timeout, verdict_on_approve, verdict_on_reject}`
/// and `exception.verdict` — which is why the alias pass does not recurse. A
/// `#[cfg(test)]` guard below re-derives this list from the schema, so nesting one
/// later fails the build instead of silently losing its aliases.
const VERDICT_BODY_KEYS: &[&str] = &[
    "verdict",
    "on_timeout",
    "verdict_on_approve",
    "verdict_on_reject",
];

/// Rewrite the D14 read-aliases in place, before the typed parse sees them.
///
/// Without this, `"on_timeout": "deny"` fails to deserialise into the frozen
/// four-value enum and takes its WHOLE artifact into `skipped[]` as
/// `body_parse_error` — a bundle authored against the previous vocabulary would
/// silently lose its rules rather than being read.
fn normalise_verdict_aliases(body: &mut serde_json::Map<String, serde_json::Value>) {
    for key in VERDICT_BODY_KEYS {
        let Some(slot) = body.get_mut(*key) else {
            continue;
        };
        let Some(raw) = slot.as_str() else {
            continue;
        };
        if let Some((_, canonical)) = VERDICT_READ_ALIASES.iter().find(|(alias, _)| *alias == raw) {
            *slot = serde_json::Value::String((*canonical).to_string());
        }
    }
}

/// A stage-1 failure: the envelope did not deserialise, so **nothing loaded**.
///
/// On the wire this is `bundle_error`, against `bundle_ack` + `skipped[]` for the
/// stage-2 case — see `docs/evaluate-protocol.md`. The two are
/// genuinely different outcomes and both ship.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BundleError {
    /// The typed envelope did not deserialise.
    Envelope(String),
    /// `client_floor.min_client_version` is above what this engine implements.
    ///
    /// The ROOT floor withholds the whole document, deliberately: a per-artifact
    /// floor degrades to one missing rule, where this one means the client cannot
    /// be trusted to evaluate any of it.
    BelowFloor {
        required: String,
        supported: &'static str,
    },
}

impl std::fmt::Display for BundleError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            BundleError::Envelope(detail) => {
                write!(f, "bundle envelope did not parse: {detail}")
            }
            BundleError::BelowFloor {
                required,
                supported,
            } => write!(
                f,
                "bundle requires client {required}; this engine implements {supported}"
            ),
        }
    }
}

/// One artifact that survived both stages: the typed envelope plus its typed body.
#[derive(Debug, Clone, PartialEq)]
pub struct LoadedArtifact {
    pub envelope: PolicyArtifact,
    pub body: ArtifactBody,
}

/// The stage-2 body, discriminated on `kind` in code because the schema cannot.
#[derive(Debug, Clone, PartialEq)]
pub enum ArtifactBody {
    T1(Box<T1PredicateTree>),
    T2(Box<T2RegisterProgram>),
    T3(Box<T3Hold>),
    Exception(Box<Exception>),
}

impl LoadedArtifact {
    pub fn artifact_id(&self) -> Option<&str> {
        self.envelope.artifact_id.as_deref()
    }

    pub fn atom_id(&self) -> Option<&str> {
        self.envelope.atom_id.as_deref()
    }

    /// The artifact's **composed** mode.
    ///
    /// `enforcement_enabled: false` is the organization-wide kill switch: every
    /// artifact composes as `monitor` regardless of its own mode, exactly as the
    /// schema-1 rule mode is. An artifact with no declared mode enforces.
    pub fn composed_mode(&self, enforcement_enabled: bool) -> PolicyMode {
        if !enforcement_enabled {
            return PolicyMode(MODE_MONITOR.to_string());
        }
        match self.envelope.mode.as_ref() {
            Some(mode) => mode.clone(),
            None => PolicyMode(MODE_ENFORCE.to_string()),
        }
    }

    /// The Tier 2 program body, when this artifact is one.
    pub fn as_t2(&self) -> Option<&T2RegisterProgram> {
        match &self.body {
            ArtifactBody::T2(body) => Some(body),
            _ => None,
        }
    }

    /// The exception body, when this artifact is one.
    pub fn as_exception(&self) -> Option<&Exception> {
        match &self.body {
            ArtifactBody::Exception(body) => Some(body),
            _ => None,
        }
    }
}

/// A bundle that has been through both stages and is ready to evaluate against.
///
/// It is a **value**, and everything derived from the bundle's own bytes — the
/// shared scan automata, the state layout — is carried inside it. Nothing about
/// it is resident: the process may cache what is derived from its inputs, never
/// what is derived from its history.
#[derive(Debug, Clone, PartialEq)]
pub struct Bundle {
    /// The evaluating artifacts (Tier 1, 2 and 3), sorted by `artifact_id` so
    /// R10's "first optimize wins" is deterministic across implementations.
    pub artifacts: Vec<LoadedArtifact>,
    /// Exception artifacts, held apart because they are applied after the tiers.
    pub exceptions: Vec<LoadedArtifact>,
    pub facts: Vec<BundleFact>,
    pub fact_refs: Vec<BundleFactRef>,
    pub effect_classes: Option<PolicyBundleEffectClasses>,
    pub directive_templates: Vec<DirectiveTemplate>,
    pub meta: Option<PolicyBundleMeta>,
    /// The organization-wide kill switch. `false` composes every artifact as
    /// `monitor`.
    pub enforcement_enabled: bool,
    /// Sized once, here, from the union of every Tier 2 program's declaration.
    pub state_layout: StateLayout,
    /// Every pattern in every surviving artifact, compiled once.
    pub scan: ScanTable,
    /// What stage 2 did not activate. **Always present, `[]` when nothing was
    /// skipped** — absent and empty are not different. Without it an authoring UI
    /// shows a policy author a green light on a rule that is not loaded.
    pub skipped: Vec<SkippedItem>,
}

/// Parse a bundle document. Stage 1 rejects; stage 2 skips one item at a time.
pub fn load(doc: serde_json::Value) -> Result<Bundle, BundleError> {
    load_with_client_version(doc, SUPPORTED_BUNDLE_CLIENT_VERSION)
}

/// [`load`], with the compared client version supplied — the seam the floor tests
/// drive.
pub fn load_with_client_version(
    mut doc: serde_json::Value,
    client_version: &str,
) -> Result<Bundle, BundleError> {
    // `rules` is REQUIRED by policy-bundle.schema.json and absent from every
    // artifact-plane bundle: schema 1's rule set is the daemon's command plane
    // and has nothing to say to the evaluator. Defaulting it here is the same
    // move `parse_bundle_tolerant` makes one level up — mutate the document,
    // then hand it to serde — and it keeps the envelope the generated type
    // rather than a second hand-written copy of it. Reported upstream: the
    // schema should carry `rules: []` as a default.
    if let Some(root) = doc.as_object_mut() {
        root.entry("rules")
            .or_insert_with(|| serde_json::Value::Array(Vec::new()));
    }

    // STAGE 1a. Retain-and-skip over `artifacts[]`, the exact move
    // `parse_bundle_tolerant` makes over `rules[]` one level up — and it is not
    // an optimisation, it is the acceptance criterion.
    //
    // `PolicyArtifact.body` is typed `serde_json::Map`, so ONE artifact whose
    // `body` is a string (or any other envelope-level malformation) would fail
    // deserialization of the WHOLE document. That is a stage-2 problem being
    // punished as a stage-1 one: it would reject the bundle and disarm every
    // other rule on the host, which is precisely what the two-stage parse exists
    // to prevent. Corpus row `parse-skips-body-not-an-object-and-keeps-the-rest`
    // pins it.
    let mut skipped: Vec<SkippedItem> = Vec::new();
    if let Some(artifacts) = doc
        .get_mut("artifacts")
        .and_then(serde_json::Value::as_array_mut)
    {
        // Borrowed, not `from_value(raw.clone())`: the deserialized artifact is
        // discarded — the real one comes out of the whole-document pass below —
        // so deep-cloning each body just to inspect it is pure cost.
        artifacts.retain(|raw| match PolicyArtifact::deserialize(raw) {
            Ok(_) => true,
            Err(_) => {
                skipped.push(SkippedItem {
                    kind: raw
                        .get("kind")
                        .and_then(serde_json::Value::as_str)
                        .unwrap_or_default()
                        .to_string(),
                    id: raw
                        .get("artifact_id")
                        .and_then(serde_json::Value::as_str)
                        .unwrap_or_default()
                        .to_string(),
                    reason: SKIP_BODY_PARSE_ERROR.to_string(),
                });
                false
            }
        });
    }

    // STAGE 1b. `PolicyBundle` is typify output over a root that is
    // `additionalProperties: false`, so an unknown root key fails here — which is
    // the documented behaviour (OL-1212) and what `client_floor` exists to prevent.
    let envelope =
        PolicyBundle::deserialize(&doc).map_err(|e| BundleError::Envelope(e.to_string()))?;

    if let Some(floor) = envelope
        .client_floor
        .as_ref()
        .and_then(|f| f.min_client_version.as_ref())
    {
        if !version_ge(client_version, floor) {
            return Err(BundleError::BelowFloor {
                required: floor.clone(),
                supported: SUPPORTED_BUNDLE_CLIENT_VERSION,
            });
        }
    }

    // STAGE 2. Sorted so two implementations iterate identically; R10's single
    // rewrite slot is awarded in this order.
    let mut envelopes = envelope.artifacts;
    envelopes.sort_by(|a, b| {
        a.artifact_id
            .as_deref()
            .unwrap_or("")
            .cmp(b.artifact_id.as_deref().unwrap_or(""))
    });

    let mut artifacts = Vec::new();
    let mut exceptions = Vec::new();

    for raw in envelopes {
        match parse_artifact(&raw, client_version) {
            Ok(body) => {
                let loaded = LoadedArtifact {
                    envelope: raw,
                    body,
                };
                if matches!(loaded.body, ArtifactBody::Exception(_)) {
                    exceptions.push(loaded);
                } else {
                    artifacts.push(loaded);
                }
            }
            Err(reason) => skipped.push(SkippedItem {
                kind: raw.kind.as_ref().map(|k| k.0.clone()).unwrap_or_default(),
                id: raw.artifact_id.clone().unwrap_or_default(),
                reason,
            }),
        }
    }

    // Deterministic: stage 1a skipped in document order, stage 2 in sorted order.
    // Two implementations must produce the same `skipped[]`, so sort the whole list.
    skipped.sort_by(|a, b| (&a.id, &a.kind).cmp(&(&b.id, &b.kind)));

    let state_layout = super::tier2::bundle_state_layout(&artifacts);
    let scan = ScanTable::compile(&artifacts);

    Ok(Bundle {
        artifacts,
        exceptions,
        facts: envelope.facts,
        fact_refs: envelope.fact_refs,
        effect_classes: envelope.effect_classes,
        directive_templates: envelope.directive_templates,
        meta: envelope.meta,
        enforcement_enabled: envelope.enforcement_enabled,
        state_layout,
        scan,
        skipped,
    })
}

/// Stage 2 for one artifact. `Err` is the pinned `reason` string, never a panic
/// and never a bundle rejection.
fn parse_artifact(envelope: &PolicyArtifact, client_version: &str) -> Result<ArtifactBody, String> {
    if let Some(floor) = envelope.min_client_version.as_ref() {
        if !version_ge(client_version, floor) {
            return Err(SKIP_BELOW_FLOOR.to_string());
        }
    }

    let kind = envelope.kind.as_ref().map(|k| k.0.as_str()).unwrap_or("");
    if !KNOWN_KINDS.contains(&kind) {
        return Err(SKIP_UNKNOWN_KIND.to_string());
    }

    // Every number on this bundle is an integer with |n| <= 2^53-1. A float
    // anywhere eliminates the RFC 8785 ES6 Number::toString divergence between
    // Python and Rust by construction, so one that slipped through is a bundle
    // the two implementations could disagree about — the item goes, not the bundle.
    let mut raw_body = envelope.body.clone();
    let body = serde_json::Value::Object(raw_body.clone());
    if contains_float(&body) {
        return Err(SKIP_FLOAT_PRESENT.to_string());
    }

    // D14 read-aliases, applied to the RAW body before the typed parse. One place
    // for all four body kinds, because `verdict` on t1/t2 and `exception` carries
    // them exactly as `t3_hold`'s three verdict fields do.
    normalise_verdict_aliases(&mut raw_body);
    let body = serde_json::Value::Object(raw_body);

    match kind {
        KIND_T1_PREDICATE_TREE => {
            let parsed: T1PredicateTree =
                serde_json::from_value(body).map_err(|_| SKIP_BODY_PARSE_ERROR.to_string())?;
            super::tier1::validate_node(parsed.node.as_ref())
                .map_err(|_| SKIP_BODY_PARSE_ERROR.to_string())?;
            super::tier1::validate_patterns(parsed.node.as_ref())
                .map_err(|_| SKIP_BAD_PATTERN.to_string())?;
            Ok(ArtifactBody::T1(Box::new(parsed)))
        }
        KIND_T2_REGISTER_PROGRAM => {
            let parsed: T2RegisterProgram =
                serde_json::from_value(body).map_err(|_| SKIP_BODY_PARSE_ERROR.to_string())?;
            super::tier2::validate_program(&parsed)
                .map_err(|_| SKIP_BODY_PARSE_ERROR.to_string())?;
            Ok(ArtifactBody::T2(Box::new(parsed)))
        }
        KIND_T3_HOLD => {
            let parsed: T3Hold =
                serde_json::from_value(body).map_err(|_| SKIP_BODY_PARSE_ERROR.to_string())?;
            super::tier3::validate_hold(&parsed).map_err(|_| SKIP_BODY_PARSE_ERROR.to_string())?;
            super::tier1::validate_patterns(parsed.trigger.as_ref())
                .map_err(|_| SKIP_BAD_PATTERN.to_string())?;
            Ok(ArtifactBody::T3(Box::new(parsed)))
        }
        KIND_EXCEPTION => {
            let parsed: Exception =
                serde_json::from_value(body).map_err(|_| SKIP_BODY_PARSE_ERROR.to_string())?;
            super::exception::validate_exception(&parsed)
                .map_err(|_| SKIP_BODY_PARSE_ERROR.to_string())?;
            Ok(ArtifactBody::Exception(Box::new(parsed)))
        }
        _ => Err(SKIP_UNKNOWN_KIND.to_string()),
    }
}

/// Whether any number anywhere under `value` is not an integer.
fn contains_float(value: &serde_json::Value) -> bool {
    match value {
        serde_json::Value::Number(n) => n.as_i64().is_none() && n.as_u64().is_none(),
        serde_json::Value::Array(items) => items.iter().any(contains_float),
        serde_json::Value::Object(map) => map.values().any(contains_float),
        _ => false,
    }
}

/// Dotted-integer version comparison; non-numeric components sort as 0.
///
/// Deliberately not `semver`: `min_client_version` is compared by the oracle the
/// same way, and a comparison the two implementations disagree about turns
/// `below_floor` into a silent divergence rather than a skipped item.
fn version_ge(left: &str, right: &str) -> bool {
    fn parts(text: &str) -> Vec<u64> {
        text.split('.')
            .map(|chunk| {
                let digits: String = chunk.chars().filter(char::is_ascii_digit).collect();
                digits.parse().unwrap_or(0)
            })
            .collect()
    }
    let (a, b) = (parts(left), parts(right));
    let width = a.len().max(b.len());
    for i in 0..width {
        let (x, y) = (
            a.get(i).copied().unwrap_or(0),
            b.get(i).copied().unwrap_or(0),
        );
        if x != y {
            return x > y;
        }
    }
    true
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::generated::types::Verdict;

    fn doc(artifacts: serde_json::Value) -> serde_json::Value {
        serde_json::json!({
            "schema_version": 2,
            "organization_id": "org",
            "revision": 1,
            "built_at": "2026-09-01T00:00:00Z",
            "enforcement_enabled": true,
            "signature": null,
            "artifacts": artifacts,
        })
    }

    fn t1(id: &str) -> serde_json::Value {
        serde_json::json!({
            "artifact_id": id,
            "kind": "t1_predicate_tree",
            "body": {"node": {"op": "leaf", "leaf": {"pred": "equals", "field": "tool.name", "value": "Bash"}}, "verdict": "block", "reason": "r"},
        })
    }

    #[test]
    fn a_missing_rules_array_is_not_a_rejection() {
        let bundle = load(doc(serde_json::json!([]))).expect("the envelope parses");
        assert!(bundle.artifacts.is_empty());
        assert!(bundle.skipped.is_empty());
    }

    #[test]
    fn a_malformed_envelope_rejects_the_whole_bundle() {
        let err = load(serde_json::json!({"schema_version": 2})).unwrap_err();
        assert!(matches!(err, BundleError::Envelope(_)));
    }

    #[test]
    fn an_unknown_kind_skips_one_item_and_activates_the_rest() {
        let bundle = load(doc(serde_json::json!([
            {"artifact_id": "a", "kind": "from_the_future", "body": {}},
            t1("b"),
        ])))
        .expect("the bundle loads");
        assert_eq!(bundle.artifacts.len(), 1, "the readable artifact activated");
        assert_eq!(
            bundle.skipped,
            vec![SkippedItem {
                kind: "from_the_future".to_string(),
                id: "a".to_string(),
                reason: SKIP_UNKNOWN_KIND.to_string(),
            }]
        );
    }

    #[test]
    fn a_body_that_is_not_an_object_skips_one_item_and_keeps_the_rest() {
        // Corpus row `parse-skips-body-not-an-object-and-keeps-the-rest`. The
        // typed envelope alone would reject the whole document here.
        let bundle = load(doc(serde_json::json!([
            {"artifact_id": "bad", "kind": "t1_predicate_tree", "body": "not-an-object"},
            t1("good"),
        ])))
        .expect("one malformed artifact does not cost the fleet its denies");
        assert_eq!(bundle.artifacts.len(), 1);
        assert_eq!(bundle.artifacts[0].artifact_id(), Some("good"));
        assert_eq!(
            bundle.skipped,
            vec![SkippedItem {
                kind: "t1_predicate_tree".to_string(),
                id: "bad".to_string(),
                reason: SKIP_BODY_PARSE_ERROR.to_string(),
            }]
        );
    }

    #[test]
    fn a_schema_one_rule_kind_on_the_artifact_plane_is_unknown_not_fatal() {
        let bundle = load(doc(serde_json::json!([
            {"artifact_id": "a", "kind": "command", "body": {}},
        ])))
        .expect("the bundle loads");
        assert_eq!(bundle.skipped[0].reason, SKIP_UNKNOWN_KIND);
    }

    #[test]
    fn a_float_anywhere_in_a_body_skips_that_item_only() {
        let bundle = load(doc(serde_json::json!([
            {"artifact_id": "a", "kind": "t2_register_program", "body": {"pre": [["CMP_GE", 0, 1.5]]}},
            t1("b"),
        ])))
        .expect("the bundle loads");
        assert_eq!(bundle.artifacts.len(), 1);
        assert_eq!(bundle.skipped[0].reason, SKIP_FLOAT_PRESENT);
    }

    #[test]
    fn an_unreadable_body_skips_that_item_only() {
        let bundle = load(doc(serde_json::json!([
            {"artifact_id": "a", "kind": "t1_predicate_tree", "body": {"node": "not an object"}},
            t1("b"),
        ])))
        .expect("the bundle loads");
        assert_eq!(bundle.artifacts.len(), 1);
        assert_eq!(bundle.skipped[0].reason, SKIP_BODY_PARSE_ERROR);
    }

    #[test]
    fn an_artifact_above_our_floor_is_skipped_and_the_rest_activates() {
        let bundle = load(doc(serde_json::json!([
            {"artifact_id": "a", "kind": "t1_predicate_tree", "min_client_version": "99.0.0", "body": {"node": {"op": "leaf"}}},
            t1("b"),
        ])))
        .expect("the bundle loads");
        assert_eq!(bundle.artifacts.len(), 1);
        assert_eq!(bundle.skipped[0].reason, SKIP_BELOW_FLOOR);
    }

    #[test]
    fn a_root_floor_above_ours_withholds_the_whole_document() {
        let mut document = doc(serde_json::json!([t1("a")]));
        document["client_floor"] = serde_json::json!({"min_client_version": "99.0.0"});
        assert!(matches!(
            load(document).unwrap_err(),
            BundleError::BelowFloor { .. }
        ));
    }

    #[test]
    fn the_corpus_floor_of_two_zero_zero_loads() {
        let mut document = doc(serde_json::json!([t1("a")]));
        document["client_floor"] = serde_json::json!({"min_client_version": "2.0.0"});
        assert!(load(document).is_ok());
    }

    #[test]
    fn exceptions_are_held_apart_from_the_evaluating_artifacts() {
        let bundle = load(doc(serde_json::json!([
            {"artifact_id": "x", "kind": "exception", "body": {"scope": "exact_action", "selector": {"action_hash": "abc"}, "verdict": "allow"}},
            t1("a"),
        ])))
        .expect("the bundle loads");
        assert_eq!(bundle.artifacts.len(), 1);
        assert_eq!(bundle.exceptions.len(), 1);
    }

    #[test]
    fn enforcement_disabled_composes_every_artifact_as_monitor() {
        let mut document = doc(serde_json::json!([t1("a")]));
        document["enforcement_enabled"] = serde_json::json!(false);
        let bundle = load(document).expect("the bundle loads");
        assert_eq!(
            bundle.artifacts[0]
                .composed_mode(bundle.enforcement_enabled)
                .0,
            MODE_MONITOR
        );
    }

    #[test]
    fn a_deny_alias_loads_instead_of_skipping_the_artifact() {
        // Corpus fixture 08-hold-alias-verdicts.json. Without the alias pass the
        // frozen four-value enum rejects "deny" and takes the WHOLE artifact into
        // skipped[] — a bundle authored against the previous vocabulary would
        // lose its rules silently rather than being read.
        let bundle = load(doc(serde_json::json!([{
            "artifact_id": "sa2",
            "kind": "t3_hold",
            "body": {
                "trigger": {"op": "leaf", "leaf": {"pred": "exists", "field": "tool.name"}},
                "on_timeout": "deny",
                "verdict_on_approve": "approve",
                "verdict_on_reject": "deny",
            },
        }])))
        .expect("the bundle loads");
        assert!(bundle.skipped.is_empty(), "{:?}", bundle.skipped);
        let ArtifactBody::T3(body) = &bundle.artifacts[0].body else {
            panic!("expected a t3_hold");
        };
        assert_eq!(body.on_timeout, Some(Verdict::Block));
        assert_eq!(body.verdict_on_approve, Some(Verdict::Ask));
        assert_eq!(body.verdict_on_reject, Some(Verdict::Block));
    }

    #[test]
    fn aliases_are_read_on_every_body_kind_not_only_the_hold() {
        for (kind, body) in [
            (
                "t1_predicate_tree",
                serde_json::json!({"node": {"op": "leaf", "leaf": {"pred": "exists", "field": "tool.name"}}, "verdict": "deny"}),
            ),
            (
                "t2_register_program",
                serde_json::json!({"pre": [], "verdict": "deny"}),
            ),
            (
                "exception",
                serde_json::json!({"scope": "exact_action", "selector": {"action_hash": "a"}, "verdict": "deny"}),
            ),
        ] {
            let bundle = load(doc(
                serde_json::json!([{ "artifact_id": "a", "kind": kind, "body": body }]),
            ))
            .unwrap_or_else(|e| panic!("{kind} did not load: {e}"));
            assert!(
                bundle.skipped.is_empty(),
                "{kind} was skipped: {:?}",
                bundle.skipped
            );
        }
    }

    #[test]
    fn an_alias_is_read_but_never_written() {
        // `Verdict` is frozen to four values by schemas 2.0 and the platform
        // depends on that. Emitting an alias would diverge the corpus comparison
        // from the platform's stored verdicts.
        for (_, canonical) in VERDICT_READ_ALIASES {
            let round_trip = serde_json::to_string(
                &serde_json::from_str::<Verdict>(&format!("\"{canonical}\"")).expect("canonical"),
            )
            .expect("serialises");
            assert_eq!(round_trip, format!("\"{canonical}\""));
        }
        for (alias, _) in VERDICT_READ_ALIASES {
            assert!(
                serde_json::from_str::<Verdict>(&format!("\"{alias}\"")).is_err(),
                "the enum itself must NOT accept {alias} — the alias is a read pass, not a fifth value"
            );
        }
    }

    #[test]
    fn every_verdict_typed_body_field_is_covered_by_the_alias_pass() {
        // The drift guard. `VERDICT_BODY_KEYS` is a hand-maintained mirror of the
        // schema, and the alias pass does not recurse because all six Verdict
        // locations are top-level properties today. Re-derive both facts from the
        // schema so adding or nesting one fails here rather than silently losing
        // its aliases.
        let schema: serde_json::Value = serde_json::from_str(
            &std::fs::read_to_string(concat!(
                env!("CARGO_MANIFEST_DIR"),
                "/schemas/policy-bundle.schema.json"
            ))
            .expect("the schema is readable"),
        )
        .expect("the schema parses");
        let defs = &schema["$defs"];
        for kind in KNOWN_KINDS {
            let properties = defs[*kind]["properties"]
                .as_object()
                .unwrap_or_else(|| panic!("$defs.{kind}.properties"));
            for (name, property) in properties {
                // The reference is CROSS-FILE — `enums.schema.json#/$defs/Verdict`,
                // not a local `#/$defs/Verdict`. Matching the local form silently
                // classified every real Verdict field as "not a Verdict".
                let is_verdict = property["$ref"]
                    .as_str()
                    .is_some_and(|r| r.ends_with("#/$defs/Verdict"));
                if is_verdict {
                    assert!(
                        VERDICT_BODY_KEYS.contains(&name.as_str()),
                        "$defs.{kind}.{name} is Verdict-typed but VERDICT_BODY_KEYS does not list it"
                    );
                }
                // Nothing Verdict-typed may hide below the top level, or the
                // non-recursing pass would miss it.
                let nested = serde_json::to_string(property).unwrap_or_default();
                if !is_verdict {
                    assert!(
                        !nested.contains("#/$defs/Verdict"),
                        // (same cross-file spelling; the substring covers both)
                        "$defs.{kind}.{name} nests a Verdict; normalise_verdict_aliases does not recurse"
                    );
                }
            }
        }
    }

    #[test]
    fn version_compare_is_dotted_integer() {
        assert!(version_ge("2.0.0", "2.0.0"));
        assert!(version_ge("2.1.0", "2.0.9"));
        assert!(!version_ge("2.0.0", "2.0.1"));
        assert!(version_ge("2.0.0", "2.0"));
        assert!(!version_ge("2.0.0-rc.1", "2.0.1"));
    }
}