openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
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
//! Per-rule validation against the bundle schema's gate (D-U13).
//!
//! The per-kind/per-action gate — which fields a `command` rule must carry, what
//! a `history_trim` rule may not carry, and so on — is expressed once, as
//! `if`/`then` conditionals inside `schemas/policy-bundle.schema.json`. The
//! platform evaluates it at authoring time to return a `422`; this module
//! evaluates the same bytes at bundle load. There is no hand-written gate
//! anywhere in the tree, so client and platform cannot drift (D-U7).
//!
//! # Why the schema reaches this module but not the codegen
//!
//! `build.rs` strips the conditionals from the copy it hands typify, which
//! hard-panics on them (D-U22). That strip is in-memory only: [`include_str!`]
//! below embeds the raw file, gate intact. One artifact, two views of it.
//!
//! # Why validation is per-rule and never per-document
//!
//! Validating the whole bundle would fail it on a single malformed rule and take
//! the organization's denies down with it — the exact inversion of the prime
//! invariant. Every failure here degrades to *skip that rule, keep the bundle
//! active*.
//!
//! # Cost
//!
//! The registry and the compiled validator are built once per process and reused
//! for every bundle thereafter. Validation itself runs once per bundle swap (the
//! poll interval defaults to 300s), never per hook event, so it is nowhere near
//! the verdict hot path.

use std::sync::LazyLock;

use serde_json::Value;

/// The bundle schema, gate included — the raw file, not the stripped tree
/// `build.rs` hands typify.
const BUNDLE_SCHEMA: &str = include_str!("../../../schemas/policy-bundle.schema.json");
/// Needed because the rule subschema `$ref`s `ChurnLayer` and `AgentFunction`
/// across files.
const ENUMS_SCHEMA: &str = include_str!("../../../schemas/enums.schema.json");

/// The closed `AgentFunction` vocabulary, in schema order.
///
/// Read by the drift test below and, via `pub(super)`, by `evaluate.rs`'s
/// `every_function_covers_the_whole_vocabulary`. One `include_str!` and one
/// spelling of the pointer between them — a second of either is exactly the
/// drift those two tests exist to catch.
#[cfg(test)]
pub(super) fn agent_function_vocabulary() -> Vec<String> {
    let enums: Value = serde_json::from_str(ENUMS_SCHEMA).expect("enums schema is JSON");
    enums
        .pointer("/$defs/AgentFunction/enum")
        .and_then(Value::as_array)
        .expect("AgentFunction is a closed enum")
        .iter()
        .map(|v| v.as_str().expect("string value").to_string())
        .collect()
}

/// `$id` of the enums document, and the key it is registered under.
///
/// `pub` because `tests/envelope_schema.rs` registers the same document against
/// the same key for the same reason (a cross-file `$ref` with no retriever
/// compiled in). One definition, so the two cannot drift apart silently.
pub const ENUMS_URI: &str = "https://schemas.openlatch.ai/client/v1/enums.schema.json";
/// Anchor for resolving the rule subschema's relative `$ref`s.
///
/// Two independent reasons this is required, and registering the enums resource
/// alone is not enough:
///
/// 1. `build.rs`'s `$ref` rewriting is codegen-only, so the file on disk keeps
///    the relative external ref `enums.schema.json#/$defs/ChurnLayer`.
/// 2. The rule subschema is extracted at `/properties/rules/items`, which
///    carries no `$id` of its own — the parent document's base URI is lost, so
///    without this the relative ref resolves against `jsonschema`'s default base
///    (`json-schema:///`) and never consults the registry key.
///
/// Keeping the key equal to the URI the published artifact actually resolves to
/// is also what lets the platform validate against the identical pair.
const BUNDLE_URI: &str = "https://schemas.openlatch.ai/client/v1/policy-bundle.schema.json";

/// JSON pointer to the rule subschema.
///
/// This is why the gate has to live inside `rules.items` rather than at the
/// document root: extracting it is how a single rule gets checked in isolation.
const RULE_POINTER: &str = "/properties/rules/items";

/// The enums document, registered so cross-file `$ref`s resolve without any
/// retriever — `jsonschema` is pulled with `default-features = false`, so no
/// HTTP or filesystem retrieval is compiled in at all.
static REGISTRY: LazyLock<jsonschema::Registry> = LazyLock::new(|| {
    let enums: Value = serde_json::from_str(ENUMS_SCHEMA).expect("embedded enums schema is JSON");
    jsonschema::Registry::new()
        .add(ENUMS_URI, jsonschema::Resource::from_contents(enums))
        .expect("enums URI is valid")
        .prepare()
        .expect("enums schema is a usable resource")
});

/// The compiled rule-subschema validator.
///
/// Compiled once. A failure to compile is held as an `Err` rather than a panic:
/// the bundle path must degrade, and a client that cannot build its validator
/// still has to keep enforcing the denies it already has.
static RULE_VALIDATOR: LazyLock<Result<jsonschema::Validator, String>> = LazyLock::new(|| {
    let bundle: Value = serde_json::from_str(BUNDLE_SCHEMA)
        .map_err(|e| format!("bundle schema is not JSON: {e}"))?;
    let subschema = bundle
        .pointer(RULE_POINTER)
        .cloned()
        .ok_or_else(|| format!("bundle schema has no subschema at {RULE_POINTER}"))?;

    jsonschema::options()
        .with_registry(&REGISTRY)
        .with_base_uri(BUNDLE_URI)
        .build(&subschema)
        .map_err(|e| format!("rule subschema does not compile: {e}"))
});

/// Check one wire rule against the gate.
///
/// Takes the typed rule and serializes it here rather than accepting arbitrary
/// JSON, and that is load-bearing rather than a convenience. typify emits
/// `skip_serializing_if = "Option::is_none"` on every optional field, so a
/// producer that shipped `"select": null` on a command rule has that key
/// normalised away before the gate sees it. JSON Schema `required` is satisfied
/// by a present-but-null key, so validating the raw object instead would let
/// that producer's mistake trip the command branch's `not` and skip every deny
/// in the fleet.
///
/// Exposing only this entry point is deliberate: the raw-`Value` form below is
/// crate-private, so no caller can reach for the unsafe one by accident.
///
/// On failure the first validation error is returned as a message for the
/// `OL-1214` log line; the caller skips that rule and keeps the bundle active.
pub fn validate_rule(rule: &crate::generated::types::PolicyRule) -> Result<(), String> {
    let value = serde_json::to_value(rule).map_err(|e| e.to_string())?;
    validate_value(&value)
}

/// Check one rule already in JSON form.
///
/// Crate-private on purpose — see [`validate_rule`]. Exists so this module's own
/// tests can build deliberately malformed rules with `json!`, which the typed
/// struct cannot express.
///
/// If the validator itself could not be compiled — which would mean the embedded
/// schema is broken, a build-time mistake rather than a runtime one — every rule
/// is **accepted**. Failing them all would disarm the fleet on a defect in the
/// gate, and the gate exists to protect enforcement, not to be able to stop it.
pub(crate) fn validate_value(rule: &Value) -> Result<(), String> {
    let validator = match RULE_VALIDATOR.as_ref() {
        Ok(v) => v,
        Err(e) => {
            tracing::warn!(
                target: "policy",
                error = %e,
                "the embedded rule subschema did not compile; accepting every rule unvalidated"
            );
            return Ok(());
        }
    };

    match validator.validate(rule) {
        Ok(()) => Ok(()),
        Err(e) => Err(format!("{} at {}", e, e.instance_path())),
    }
}

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

    fn command_rule() -> Value {
        json!({
            "rule_id": "OL-CMD-001",
            "kind": "command",
            "match_pattern": "*rm -rf*",
            "action": "deny",
            "mode": "enforce",
            "severity": "critical",
            "reason": "Recursive delete"
        })
    }

    fn request_rule(action: &str) -> Value {
        json!({
            "rule_id": "OL-REQ-001",
            "kind": "request",
            "rule_version": 1,
            "action": action,
            "mode": "observe",
            "severity": "low",
            "reason": "Cache prefix churn"
        })
    }

    #[test]
    fn the_embedded_subschema_compiles() {
        assert!(
            RULE_VALIDATOR.as_ref().is_ok(),
            "{:?}",
            RULE_VALIDATOR.as_ref().err()
        );
    }

    #[test]
    fn canonical_command_rule_passes() {
        assert_eq!(validate_value(&command_rule()), Ok(()));
    }

    #[test]
    fn command_rule_without_match_pattern_is_rejected() {
        let mut rule = command_rule();
        rule.as_object_mut().unwrap().remove("match_pattern");
        assert!(validate_value(&rule).is_err());
    }

    /// `rule_version` is a request-plane field, and a command rule carrying it
    /// is the eighth key on the wire. A client that predates this schema has
    /// exactly seven known fields and `#[serde(deny_unknown_fields)]`, so that
    /// eighth key fails deserialization of the **whole bundle** and the
    /// organization loses every deny — not one skipped rule.
    ///
    /// The field's own description already said "absent on kind=command"; until
    /// this branch listed it, nothing enforced that half, and the only thing
    /// standing between a platform bug and a disarmed fleet was the platform
    /// remembering to scope the write to `kind == 'request'`.
    #[test]
    fn command_rule_carrying_rule_version_is_rejected() {
        let mut rule = command_rule();
        rule.as_object_mut()
            .unwrap()
            .insert("rule_version".into(), json!(1));
        assert!(validate_value(&rule).is_err());
    }

    #[test]
    fn command_rule_carrying_request_fields_is_rejected() {
        let mut rule = command_rule();
        rule.as_object_mut()
            .unwrap()
            .insert("select".into(), json!({ "min_messages": 4 }));
        assert!(validate_value(&rule).is_err());
    }

    /// A present-but-null key satisfies JSON Schema `required`, so a platform
    /// that widened its projection without dropping nulls would fail every
    /// command rule here. The client never sees that shape — it validates a
    /// rule re-serialized from the typed struct, and typify emits
    /// `skip_serializing_if` on every optional field — but the gate's behaviour
    /// is pinned so the platform-side contract is unambiguous.
    #[test]
    fn a_null_valued_request_key_still_violates_the_command_branch() {
        let mut rule = command_rule();
        rule.as_object_mut()
            .unwrap()
            .insert("select".into(), Value::Null);
        assert!(validate_value(&rule).is_err());
    }

    /// Vacuous truth guard: every `if` carries its own `required`, so a rule
    /// with no `kind` cannot slip past every branch by matching none of them.
    #[test]
    fn rule_missing_kind_is_rejected() {
        let mut rule = command_rule();
        rule.as_object_mut().unwrap().remove("kind");
        assert!(validate_value(&rule).is_err());
    }

    #[test]
    fn command_kind_with_a_request_action_is_rejected() {
        let mut rule = command_rule();
        rule.as_object_mut()
            .unwrap()
            .insert("action".into(), json!("prefix_reorder"));
        assert!(validate_value(&rule).is_err());
    }

    /// Without the registry + base URI, the `ChurnLayer` `$ref` is unresolvable
    /// and this call errors on a valid rule.
    #[test]
    fn prefix_reorder_with_exclude_layers_validates() {
        let mut rule = request_rule("prefix_reorder");
        rule.as_object_mut().unwrap().insert(
            "select".into(),
            json!({ "model_in": ["claude-opus-5"], "exclude_layers": ["tools"] }),
        );
        assert_eq!(validate_value(&rule), Ok(()));
    }

    /// The same `$ref`, proving it is actually enforced rather than skipped.
    #[test]
    fn an_unknown_churn_layer_is_rejected() {
        let mut rule = request_rule("prefix_reorder");
        rule.as_object_mut()
            .unwrap()
            .insert("select".into(), json!({ "exclude_layers": ["nope"] }));
        assert!(validate_value(&rule).is_err());
    }

    /// `conditions[].value` is the closed `AgentFunction` enum, resolved through
    /// the same cross-file `$ref` machinery as `ChurnLayer`. The valid rule is
    /// asserted first so the rejection below is proven to come from the enum
    /// and not from an unresolvable reference failing every rule alike. This
    /// is per rule: a nonsense value costs that rule, never the bundle.
    #[test]
    fn a_nonsense_function_in_conditions_is_gate_rejected() {
        let scoped = |value: Value| {
            let mut rule = command_rule();
            rule.as_object_mut().unwrap().insert(
                "conditions".into(),
                json!([{ "field": "agent.function", "op": "in", "value": value }]),
            );
            rule
        };

        assert_eq!(
            validate_value(&scoped(json!(["marketing", "unknown"]))),
            Ok(())
        );
        assert!(validate_value(&scoped(json!(["astrology"]))).is_err());
        // The rejection is per value, not per set: one bad member alongside a
        // good one still fails the rule.
        assert!(validate_value(&scoped(json!(["marketing", "astrology"]))).is_err());
        // `minItems: 1` — an empty set would match nothing while reading as a
        // scoped rule. typify does not turn `minItems` into a constrained
        // newtype, so this is the only place the bound is enforced.
        assert!(validate_value(&scoped(json!([]))).is_err());
    }

    /// v1 evaluates conditions on the command plane only. A request rule that
    /// carries them is rejected outright rather than applied unscoped — the
    /// boundary listener has no agent-context evaluation, so honouring the rule
    /// while ignoring its narrowing would apply it to more traffic than its
    /// author wrote it for. Both branches are pinned so the arm's `not` is
    /// proven to name `conditions` and not merely `match_pattern`.
    #[test]
    fn conditions_on_a_request_rule_are_gate_rejected() {
        let conditions = json!([{ "field": "agent.function", "op": "in", "value": ["sales"] }]);
        for action in ["prefix_reorder", "history_trim", "prompt_edit"] {
            let mut rule = request_rule(action);
            let obj = rule.as_object_mut().unwrap();
            obj.insert("conditions".into(), conditions.clone());
            if action == "history_trim" {
                obj.insert("params".into(), json!({ "keep_messages": 20 }));
            }
            if action == "prompt_edit" {
                obj.insert("params".into(), json!({ "marker": "<!--ol-->" }));
            }
            assert!(validate_value(&rule).is_err(), "action {action}");
        }
        // The command plane still admits the identical block.
        let mut command = command_rule();
        command
            .as_object_mut()
            .unwrap()
            .insert("conditions".into(), conditions);
        assert_eq!(validate_value(&command), Ok(()));
    }

    /// `prefix_reorder` carried a blanket `not: {required: [params]}` until the
    /// savings panel needed a rule to say WHICH caching intervention it means.
    /// The ban is now a per-key narrowing, so the object is admitted with only
    /// the key this action reads.
    #[test]
    fn prefix_reorder_accepts_a_mechanism() {
        for mechanism in ["insert_breakpoints", "reorder_blocks"] {
            let mut rule = request_rule("prefix_reorder");
            rule.as_object_mut()
                .unwrap()
                .insert("params".into(), json!({ "mechanism": mechanism }));
            assert_eq!(validate_value(&rule), Ok(()), "mechanism {mechanism}");
        }
    }

    /// The relaxation must not have become a REQUIREMENT. Every
    /// `prefix_reorder` rule authored before `mechanism` existed carries no
    /// `params` at all, and each one is validated individually at bundle load —
    /// so a `required: [params]` here would silently drop every one of them.
    #[test]
    fn prefix_reorder_still_validates_without_params() {
        assert_eq!(validate_value(&request_rule("prefix_reorder")), Ok(()));

        let mut empty = request_rule("prefix_reorder");
        empty
            .as_object_mut()
            .unwrap()
            .insert("params".into(), json!({}));
        assert_eq!(validate_value(&empty), Ok(()));
    }

    /// The narrowing is per key, not "params is now free". The three keys the
    /// other two actions own stay rejected here — a `prefix_reorder` rule
    /// carrying `keep_messages` describes a transform this action cannot make.
    #[test]
    fn prefix_reorder_rejects_the_other_actions_params() {
        for params in [
            json!({ "keep_messages": 20 }),
            json!({ "marker": "<!--ol-->" }),
            json!({ "max_system_tokens": 2000 }),
            // Paired with a legal key, so this pins the rejection rather than
            // an accidental "any params at all" failure.
            json!({ "mechanism": "reorder_blocks", "keep_messages": 20 }),
        ] {
            let mut rule = request_rule("prefix_reorder");
            rule.as_object_mut()
                .unwrap()
                .insert("params".into(), params.clone());
            assert!(validate_value(&rule).is_err(), "params {params}");
        }
    }

    /// D-U15, the reason `mechanism` is a plain string and not an `enum`: a
    /// value this client does not recognise must reach it as data, so a newer
    /// platform can name a third intervention without every older client
    /// rejecting the rule. `evaluate_authored` decides what to do with an
    /// unfamiliar mechanism; the gate's job is only to let it through.
    #[test]
    fn an_out_of_vocabulary_mechanism_is_accepted() {
        let mut rule = request_rule("prefix_reorder");
        rule.as_object_mut()
            .unwrap()
            .insert("params".into(), json!({ "mechanism": "some_future_lever" }));
        assert_eq!(validate_value(&rule), Ok(()));
    }

    /// The narrowing runs both ways: `mechanism` is read by `prefix_reorder`
    /// only, so a rule that sets it on either other action is rejected rather
    /// than silently ignored — the same posture the other three params keys get.
    #[test]
    fn mechanism_is_rejected_on_the_other_request_actions() {
        let mut trim = request_rule("history_trim");
        trim.as_object_mut().unwrap().insert(
            "params".into(),
            json!({ "keep_messages": 20, "mechanism": "reorder_blocks" }),
        );
        assert!(validate_value(&trim).is_err());

        let mut edit = request_rule("prompt_edit");
        edit.as_object_mut().unwrap().insert(
            "params".into(),
            json!({ "marker": "<!--ol-->", "mechanism": "reorder_blocks" }),
        );
        assert!(validate_value(&edit).is_err());
    }

    #[test]
    fn request_rule_without_rule_version_is_rejected() {
        let mut rule = request_rule("prefix_reorder");
        rule.as_object_mut().unwrap().remove("rule_version");
        assert!(validate_value(&rule).is_err());
    }

    #[test]
    fn request_rule_with_a_match_pattern_is_rejected() {
        let mut rule = request_rule("prefix_reorder");
        rule.as_object_mut()
            .unwrap()
            .insert("match_pattern".into(), json!("*rm*"));
        assert!(validate_value(&rule).is_err());
    }

    #[test]
    fn history_trim_requires_keep_messages_and_rejects_the_others() {
        let mut ok = request_rule("history_trim");
        ok.as_object_mut()
            .unwrap()
            .insert("params".into(), json!({ "keep_messages": 20 }));
        assert_eq!(validate_value(&ok), Ok(()));

        let mut no_params = request_rule("history_trim");
        no_params
            .as_object_mut()
            .unwrap()
            .insert("select".into(), json!({ "min_messages": 40 }));
        assert!(validate_value(&no_params).is_err());

        let mut with_marker = request_rule("history_trim");
        with_marker.as_object_mut().unwrap().insert(
            "params".into(),
            json!({ "keep_messages": 20, "marker": "x" }),
        );
        assert!(validate_value(&with_marker).is_err());

        let mut with_layers = request_rule("history_trim");
        let obj = with_layers.as_object_mut().unwrap();
        obj.insert("params".into(), json!({ "keep_messages": 20 }));
        obj.insert("select".into(), json!({ "exclude_layers": ["tools"] }));
        assert!(validate_value(&with_layers).is_err());
    }

    #[test]
    fn prompt_edit_takes_exactly_one_of_marker_or_max_system_tokens() {
        let mut marker = request_rule("prompt_edit");
        marker
            .as_object_mut()
            .unwrap()
            .insert("params".into(), json!({ "marker": "<!--ol-->" }));
        assert_eq!(validate_value(&marker), Ok(()));

        let mut tokens = request_rule("prompt_edit");
        tokens
            .as_object_mut()
            .unwrap()
            .insert("params".into(), json!({ "max_system_tokens": 2000 }));
        assert_eq!(validate_value(&tokens), Ok(()));

        let mut both = request_rule("prompt_edit");
        both.as_object_mut().unwrap().insert(
            "params".into(),
            json!({ "marker": "<!--ol-->", "max_system_tokens": 2000 }),
        );
        assert!(validate_value(&both).is_err());

        let mut neither = request_rule("prompt_edit");
        neither
            .as_object_mut()
            .unwrap()
            .insert("params".into(), json!({ "keep_messages": 5 }));
        assert!(validate_value(&neither).is_err());
    }

    /// A long marker must pass. If a `maxLength` ever reaches this schema,
    /// typify turns the field into a constrained newtype whose `Deserialize`
    /// fails the WHOLE bundle, taking the organization's denies with it.
    #[test]
    fn a_long_marker_is_not_constrained_by_the_schema() {
        let mut rule = request_rule("prompt_edit");
        rule.as_object_mut()
            .unwrap()
            .insert("params".into(), json!({ "marker": "x".repeat(300) }));
        assert_eq!(validate_value(&rule), Ok(()));
    }

    /// The `examples` block is what a platform engineer reads to learn the
    /// shape, and typify copies it verbatim into the generated doc comments. An
    /// example that the gate itself rejects is documentation that teaches a
    /// `422`.
    #[test]
    fn every_schema_example_passes_the_gate() {
        let bundle: Value = serde_json::from_str(BUNDLE_SCHEMA).expect("bundle schema is JSON");
        let examples = bundle["examples"].as_array().expect("examples block");
        let mut checked = 0;

        for (i, example) in examples.iter().enumerate() {
            for rule in example["rules"].as_array().expect("example rules") {
                assert_eq!(
                    validate_value(rule),
                    Ok(()),
                    "example {i}, rule {}",
                    rule["rule_id"]
                );
                checked += 1;
            }
        }
        assert!(checked >= 4, "the examples must exercise both planes");
    }

    /// The 14-value vocabulary is written twice — once as the closed
    /// `AgentFunction` enum in `enums.schema.json` (what a rule's
    /// `conditions[].value` items must be), once as `x-known-values` on the
    /// open `client_config.agent_context.function` string. The split is
    /// deliberate (a bad value must cost one rule, never the whole bundle), the
    /// drift is not: a platform that starts sending a `function` this list
    /// omits would still parse here and simply match no scoped rule.
    ///
    /// Nothing else catches it. `build.rs` codegens `known_values.rs` from
    /// `HookEventType` / `AgentType` only, and `schemas-compat-check.mjs`
    /// strips `x-known-values` before diffing — so this assertion is the guard.
    #[test]
    fn the_agent_function_vocabulary_is_written_identically_in_both_schemas() {
        let bundle: Value = serde_json::from_str(BUNDLE_SCHEMA).expect("bundle schema is JSON");
        let open = bundle
            .pointer("/properties/client_config/properties/agent_context/properties/function/x-known-values")
            .expect("agent_context.function declares x-known-values");

        // Order too: the generated Rust enum's variant order comes from the
        // closed list, and a reader comparing the two files should not have to
        // sort them in their head.
        assert_eq!(open, &json!(agent_function_vocabulary()));
    }

    /// Forward compatibility survives the gate: an unrecognised `kind` matches
    /// no branch, so the gate passes it and `project_rule` does the skipping.
    /// Closing this would move a v1.1 tolerance case into a hard failure.
    #[test]
    fn an_unknown_kind_passes_the_gate_untouched() {
        let mut rule = command_rule();
        rule.as_object_mut()
            .unwrap()
            .insert("kind".into(), json!("network"));
        assert_eq!(validate_value(&rule), Ok(()));
    }
}