Skip to main content

assay_core/model/
validation.rs

1use crate::on_error::ErrorPolicy;
2
3use super::types::{
4    EvalConfig, Expected, SequenceRule, Settings, TestCase, TestStatus, ThresholdingConfig,
5};
6
7/// Tolerance used by the semantic-similarity evaluator at its pass boundary.
8pub const SEMANTIC_SIMILARITY_EPSILON: f64 = 1e-6;
9
10pub(crate) fn is_default_otel(o: &crate::config::otel::OtelConfig) -> bool {
11    o == &crate::config::otel::OtelConfig::default()
12}
13
14pub(crate) fn is_default_thresholds(t: &crate::thresholds::ThresholdConfig) -> bool {
15    t == &crate::thresholds::ThresholdConfig::default()
16}
17
18pub(crate) fn is_default_error_policy(p: &ErrorPolicy) -> bool {
19    *p == ErrorPolicy::default()
20}
21
22pub(crate) fn is_default_settings(s: &Settings) -> bool {
23    s == &Settings::default()
24}
25
26/// The field or field group that leaves `expected` without an effective check.
27///
28/// Parsing and execution share this predicate because both must reject an
29/// explicitly inert assertion. Serialization deliberately uses a narrower
30/// predicate: only the synthetic omitted-key sentinel may disappear on write.
31pub(crate) fn vacuous_expected_field(e: &Expected) -> Option<&'static str> {
32    match e {
33        Expected::MustContain { must_contain } if must_contain.iter().all(String::is_empty) => {
34            Some("must_contain")
35        }
36        Expected::MustNotContain { must_not_contain } if must_not_contain.is_empty() => {
37            Some("must_not_contain")
38        }
39        Expected::RegexMatch { pattern, .. } if pattern.is_empty() => Some("pattern"),
40        Expected::SemanticSimilarityTo { min_score, .. }
41            if *min_score <= -1.0 + SEMANTIC_SIMILARITY_EPSILON =>
42        {
43            Some("min_score")
44        }
45        Expected::ArgsValid { policy, schema }
46            if schema.as_ref().is_some_and(args_policy_asserts_nothing)
47                || (policy.is_none() && schema.is_none()) =>
48        {
49            Some("policy/schema")
50        }
51        Expected::SequenceValid {
52            policy,
53            sequence,
54            rules,
55        } if (sequence.is_none() && rules.is_none() && policy.is_none())
56            || (sequence.is_none() && rules.as_ref().is_some_and(Vec::is_empty)) =>
57        {
58            Some("policy/sequence/rules")
59        }
60        Expected::ToolOutputValid { schemas }
61            if schemas.as_ref().is_none_or(schema_map_asserts_nothing) =>
62        {
63            Some("schemas")
64        }
65        Expected::ToolBlocklist { blocked } if blocked.is_empty() => Some("blocked"),
66        _ => None,
67    }
68}
69
70/// Decide whether an `args_valid` policy asserts anything.
71///
72/// For a structured policy this mirrors the control-surface half of
73/// `validate_args_policy_value` exactly — merged root and `tools` allow/deny
74/// lists, `any` for the universality test, and `deny` as the only enforcement
75/// mode that makes a policy effective. Two approximations of the same rule
76/// drift; `args_policy_oracles_agree` pins them together.
77fn args_policy_asserts_nothing(schema: &serde_json::Value) -> bool {
78    let Some(root) = schema.as_object() else {
79        return schema_map_asserts_nothing(schema);
80    };
81    // The root is itself a JSON Schema rather than a tool map.
82    if root
83        .keys()
84        .any(|key| key != "$defs" && is_json_schema_keyword(key))
85    {
86        return false;
87    }
88    if has_structured_args_policy_shape(schema) {
89        let schemas_assert = root
90            .get("schemas")
91            .is_some_and(|schemas| !schema_map_asserts_nothing(schemas));
92        if schemas_assert || structured_controls_assert(root) {
93            return false;
94        }
95        // Claim vacuity only for a policy this rule fully understands. Anything
96        // else — an unenforced field, a malformed list, a non-mapping `schemas`
97        // — has a specific downstream diagnosis that is more useful than a
98        // generic "asserts nothing", so defer to it.
99        return structured_policy_is_fully_understood(root);
100    }
101    schema_map_asserts_nothing(schema)
102}
103
104/// Mirror of `validate_args_policy_value`'s control-surface effectiveness rule.
105/// Whether every part of a structured policy is something this rule models.
106///
107/// The vacuity verdict is only honest for a policy it fully understands. For
108/// anything else the execution-time check has a specific message ("not enforced
109/// by this evaluator", "must be a list", "must be a mapping") that is far more
110/// useful than "asserts nothing", so those defer to it.
111fn structured_policy_is_fully_understood(
112    root: &serde_json::Map<String, serde_json::Value>,
113) -> bool {
114    const KNOWN_ROOT: [&str; 8] = [
115        "version",
116        "name",
117        "tools",
118        "schemas",
119        "enforcement",
120        "allow",
121        "deny",
122        "$defs",
123    ];
124    if !root.keys().all(|key| KNOWN_ROOT.contains(&key.as_str())) {
125        return false;
126    }
127    fn well_formed_list(value: Option<&serde_json::Value>) -> bool {
128        value.is_none_or(|value| {
129            value
130                .as_array()
131                .is_some_and(|entries| entries.iter().all(serde_json::Value::is_string))
132        })
133    }
134    if !well_formed_list(root.get("allow")) || !well_formed_list(root.get("deny")) {
135        return false;
136    }
137    if let Some(tools) = root.get("tools") {
138        let Some(tools) = tools.as_object() else {
139            return false;
140        };
141        // Execution enforces only `allow`/`deny`; every other key gets its own
142        // "not enforced by this evaluator" diagnosis.
143        if !tools.keys().all(|k| matches!(k.as_str(), "allow" | "deny")) {
144            return false;
145        }
146        if !well_formed_list(tools.get("allow")) || !well_formed_list(tools.get("deny")) {
147            return false;
148        }
149    }
150    if root.get("schemas").is_some_and(|s| !s.is_object()) {
151        return false;
152    }
153    root.get("enforcement")
154        .is_none_or(serde_json::Value::is_object)
155}
156
157fn structured_controls_assert(root: &serde_json::Map<String, serde_json::Value>) -> bool {
158    fn list(value: Option<&serde_json::Value>) -> Vec<&str> {
159        value
160            .and_then(serde_json::Value::as_array)
161            .map(|entries| {
162                entries
163                    .iter()
164                    .filter_map(serde_json::Value::as_str)
165                    .collect()
166            })
167            .unwrap_or_default()
168    }
169    let mut allow = list(root.get("allow"));
170    let mut deny = list(root.get("deny"));
171    if let Some(tools) = root.get("tools").and_then(serde_json::Value::as_object) {
172        allow.extend(list(tools.get("allow")));
173        deny.extend(list(tools.get("deny")));
174    }
175    if !deny.is_empty()
176        || (!allow.is_empty()
177            && !allow
178                .iter()
179                .any(|pattern| is_universal_tool_pattern(pattern)))
180    {
181        return true;
182    }
183    root.get("enforcement")
184        .and_then(serde_json::Value::as_object)
185        .and_then(|enforcement| enforcement.get("unconstrained_tools"))
186        .and_then(serde_json::Value::as_str)
187        == Some("deny")
188}
189
190fn schema_map_asserts_nothing(value: &serde_json::Value) -> bool {
191    value.as_object().is_some_and(|schemas| {
192        let shared_defs = schemas.get("$defs").and_then(serde_json::Value::as_object);
193        schemas
194            .iter()
195            .filter(|(tool, _)| tool.as_str() != "$defs")
196            .all(|(_, schema)| {
197                let mut materialized = schema.clone();
198                if let (Some(shared_defs), Some(schema)) =
199                    (shared_defs, materialized.as_object_mut())
200                {
201                    match schema.get_mut("$defs") {
202                        Some(serde_json::Value::Object(local_defs)) => {
203                            if shared_defs.keys().any(|name| local_defs.contains_key(name)) {
204                                return false;
205                            }
206                            local_defs.extend(shared_defs.clone());
207                        }
208                        Some(_) => return false,
209                        None => {
210                            schema.insert(
211                                "$defs".to_string(),
212                                serde_json::Value::Object(shared_defs.clone()),
213                            );
214                        }
215                    }
216                }
217                schema_asserts_nothing(&materialized)
218            })
219    })
220}
221
222fn schema_asserts_nothing(schema: &serde_json::Value) -> bool {
223    let dialect = SchemaDialect::from_schema(schema);
224    schema_asserts_nothing_inner(schema, schema, dialect, 0)
225}
226
227#[derive(Clone, Copy)]
228enum SchemaDialect {
229    Draft4,
230    Draft6,
231    Draft7,
232    Modern,
233}
234
235impl SchemaDialect {
236    fn from_schema(schema: &serde_json::Value) -> Self {
237        match schema
238            .get("$schema")
239            .and_then(serde_json::Value::as_str)
240            .unwrap_or_default()
241        {
242            value if value.contains("draft-04") => Self::Draft4,
243            value if value.contains("draft-06") => Self::Draft6,
244            value if value.contains("draft-07") => Self::Draft7,
245            _ => Self::Modern,
246        }
247    }
248
249    fn is_legacy(self) -> bool {
250        matches!(self, Self::Draft4 | Self::Draft6 | Self::Draft7)
251    }
252}
253
254fn schema_asserts_nothing_inner(
255    schema: &serde_json::Value,
256    root: &serde_json::Value,
257    dialect: SchemaDialect,
258    depth: usize,
259) -> bool {
260    if depth > 64 {
261        return false;
262    }
263    match schema {
264        serde_json::Value::Bool(value) => *value,
265        serde_json::Value::Object(schema) => {
266            let direct_assertion = schema.iter().any(|(keyword, value)| {
267                schema_keyword_asserts(keyword, value, schema, root, dialect, depth + 1)
268            });
269            let conditional_assertion = conditional_asserts(schema, root, dialect, depth + 1);
270            !direct_assertion && !conditional_assertion
271        }
272        _ => false,
273    }
274}
275
276fn schema_keyword_asserts(
277    keyword: &str,
278    value: &serde_json::Value,
279    containing_schema: &serde_json::Map<String, serde_json::Value>,
280    root: &serde_json::Value,
281    dialect: SchemaDialect,
282    depth: usize,
283) -> bool {
284    match keyword {
285        "$ref" => local_ref_target(value, root)
286            .is_none_or(|target| !schema_asserts_nothing_inner(target, root, dialect, depth)),
287        "$dynamicRef" | "$recursiveRef" | "type" | "enum" | "multipleOf" | "maximum"
288        | "minimum" | "maxLength" | "maxItems" | "maxProperties" => true,
289        "const" => !matches!(dialect, SchemaDialect::Draft4),
290        "exclusiveMaximum" | "exclusiveMinimum" => value.as_bool() != Some(false),
291        "pattern" => value.as_str() != Some(""),
292        "minLength" | "minItems" | "minProperties" => {
293            value.as_u64().is_none_or(|minimum| minimum > 0)
294        }
295        "uniqueItems" => value.as_bool() != Some(false),
296        "required" => value.as_array().is_some_and(|entries| !entries.is_empty()),
297        "dependentRequired" if !dialect.is_legacy() => value.as_object().is_some_and(|entries| {
298            entries
299                .values()
300                .any(|required| required.as_array().is_some_and(|names| !names.is_empty()))
301        }),
302        "properties" | "patternProperties" => value.as_object().is_some_and(|schemas| {
303            schemas
304                .values()
305                .any(|schema| !schema_asserts_nothing_inner(schema, root, dialect, depth))
306        }),
307        "dependentSchemas" if !dialect.is_legacy() => value.as_object().is_some_and(|schemas| {
308            schemas
309                .values()
310                .any(|schema| !schema_asserts_nothing_inner(schema, root, dialect, depth))
311        }),
312        // jsonschema compiles `dependencies` for every draft that declares the
313        // applicator vocabulary (keywords/mod.rs: `(_, "dependencies")`), so it
314        // asserts under the modern dialect too even though the spec dropped it.
315        "dependencies" => value.as_object().is_some_and(|dependencies| {
316            dependencies.values().any(|dependency| {
317                dependency
318                    .as_array()
319                    .is_some_and(|required| !required.is_empty())
320                    || ((dependency.is_object() || dependency.is_boolean())
321                        && !schema_asserts_nothing_inner(dependency, root, dialect, depth))
322            })
323        }),
324        "additionalProperties" | "items" => {
325            !schema_asserts_nothing_inner(value, root, dialect, depth)
326        }
327        "unevaluatedProperties" | "unevaluatedItems" if !dialect.is_legacy() => {
328            !schema_asserts_nothing_inner(value, root, dialect, depth)
329        }
330        "propertyNames" if !matches!(dialect, SchemaDialect::Draft4) => {
331            !schema_asserts_nothing_inner(value, root, dialect, depth)
332        }
333        "additionalItems"
334            if dialect.is_legacy()
335                && containing_schema
336                    .get("items")
337                    .is_some_and(serde_json::Value::is_array) =>
338        {
339            !schema_asserts_nothing_inner(value, root, dialect, depth)
340        }
341        "prefixItems" if !dialect.is_legacy() => value.as_array().is_some_and(|schemas| {
342            schemas
343                .iter()
344                .any(|schema| !schema_asserts_nothing_inner(schema, root, dialect, depth))
345        }),
346        "allOf" => value.as_array().is_some_and(|schemas| {
347            schemas
348                .iter()
349                .any(|schema| !schema_asserts_nothing_inner(schema, root, dialect, depth))
350        }),
351        "anyOf" => value.as_array().is_some_and(|schemas| {
352            schemas.is_empty()
353                || schemas
354                    .iter()
355                    .all(|schema| !schema_asserts_nothing_inner(schema, root, dialect, depth))
356        }),
357        "oneOf" => value
358            .as_array()
359            .is_some_and(|schemas| match schemas.as_slice() {
360                [] => true,
361                [schema] => !schema_asserts_nothing_inner(schema, root, dialect, depth),
362                schemas
363                    if schemas
364                        .iter()
365                        .filter(|schema| schema.as_bool() == Some(true))
366                        .count()
367                        == 1
368                        && schemas.iter().all(serde_json::Value::is_boolean) =>
369                {
370                    false
371                }
372                _ => true,
373            }),
374        "not" => value != &serde_json::Value::Bool(false),
375        "contains" if matches!(dialect, SchemaDialect::Draft4) => false,
376        "contains" if dialect.is_legacy() => true,
377        "contains" => {
378            containing_schema
379                .get("minContains")
380                .and_then(serde_json::Value::as_u64)
381                != Some(0)
382                || containing_schema.contains_key("maxContains")
383        }
384        "format" if dialect.is_legacy() => is_known_format(value),
385        _ => false,
386    }
387}
388
389fn conditional_asserts(
390    schema: &serde_json::Map<String, serde_json::Value>,
391    root: &serde_json::Value,
392    dialect: SchemaDialect,
393    depth: usize,
394) -> bool {
395    if matches!(dialect, SchemaDialect::Draft4 | SchemaDialect::Draft6) {
396        return false;
397    }
398    let Some(condition) = schema.get("if") else {
399        return false;
400    };
401    let branch_asserts = |keyword| {
402        schema
403            .get(keyword)
404            .is_some_and(|branch| !schema_asserts_nothing_inner(branch, root, dialect, depth))
405    };
406    match condition.as_bool() {
407        Some(true) => branch_asserts("then"),
408        Some(false) => branch_asserts("else"),
409        None => branch_asserts("then") || branch_asserts("else"),
410    }
411}
412
413fn local_ref_target<'a>(
414    reference: &serde_json::Value,
415    root: &'a serde_json::Value,
416) -> Option<&'a serde_json::Value> {
417    let reference = reference.as_str()?;
418    let fragment = if let Some(fragment) = reference.strip_prefix('#') {
419        fragment
420    } else {
421        let root_id = root.get("$id")?.as_str()?;
422        reference.strip_prefix(root_id)?.strip_prefix('#')?
423    };
424    if fragment.is_empty() {
425        return None;
426    }
427    root.pointer(fragment)
428}
429
430fn is_known_format(value: &serde_json::Value) -> bool {
431    matches!(
432        value.as_str(),
433        Some(
434            "date"
435                | "date-time"
436                | "email"
437                | "hostname"
438                | "ipv4"
439                | "ipv6"
440                | "regex"
441                | "time"
442                | "uri"
443                | "uri-reference"
444                | "uri-template"
445                | "uuid"
446        )
447    )
448}
449
450/// Explain an `Expected` shape that the current metric set cannot execute as written.
451pub(crate) fn non_executable_expected_reason(e: &Expected) -> Option<&'static str> {
452    match e {
453        Expected::JudgeCriteria { .. } => Some("judge_criteria has no registered evaluator"),
454        Expected::SequenceValid {
455            rules: Some(rules), ..
456        } => rules.iter().find_map(|rule| match rule {
457            SequenceRule::Require { .. }
458            | SequenceRule::Blocklist { .. }
459            | SequenceRule::Before { .. } => None,
460            SequenceRule::Eventually { .. } => {
461                Some("sequence rule eventually is not executable by sequence_valid")
462            }
463            SequenceRule::MaxCalls { .. } => {
464                Some("sequence rule max_calls is not executable by sequence_valid")
465            }
466            SequenceRule::After { .. } => {
467                Some("sequence rule after is not executable by sequence_valid")
468            }
469            SequenceRule::NeverAfter { .. } => {
470                Some("sequence rule never_after is not executable by sequence_valid")
471            }
472            SequenceRule::Sequence { .. } => {
473                Some("sequence rule sequence is not executable by sequence_valid")
474            }
475        }),
476        _ => None,
477    }
478}
479
480pub(crate) fn ineffective_expected_reason(e: &Expected) -> Option<&'static str> {
481    match e {
482        Expected::MustNotContain { must_not_contain }
483            if must_not_contain.iter().any(String::is_empty) =>
484        {
485            Some("must_not_contain contains an empty string, so no response can pass")
486        }
487        Expected::RegexNotMatch { pattern, .. } if pattern.is_empty() => {
488            Some("an empty regex_not_match pattern matches every response, so no response can pass")
489        }
490        Expected::SequenceValid {
491            rules: Some(rules), ..
492        } if rules.iter().any(|rule| {
493            matches!(
494                rule,
495                SequenceRule::Before { first, then } if first == then
496            )
497        }) =>
498        {
499            Some("a before rule with identical tools cannot constrain a trace")
500        }
501        _ => None,
502    }
503}
504
505/// Reject any expected value that cannot safely reach metric dispatch.
506pub(crate) fn validate_expected_for_execution(e: &Expected) -> anyhow::Result<()> {
507    if matches!(e, Expected::Reference { .. }) {
508        anyhow::bail!("unresolved `$ref` cannot be executed; resolve or migrate it first");
509    }
510    if let Some(field) = vacuous_expected_field(e) {
511        anyhow::bail!("`{field}` asserts nothing");
512    }
513    if let Some(reason) = non_executable_expected_reason(e) {
514        anyhow::bail!("expected block is not executable: {reason}");
515    }
516    if let Some(reason) = ineffective_expected_reason(e) {
517        anyhow::bail!("{reason}");
518    }
519    validate_static_inputs(e)?;
520    Ok(())
521}
522
523fn validate_static_inputs(e: &Expected) -> anyhow::Result<()> {
524    match e {
525        Expected::RegexMatch { pattern, flags } | Expected::RegexNotMatch { pattern, flags } => {
526            let mut builder = regex::RegexBuilder::new(pattern);
527            for flag in flags {
528                match flag.as_str() {
529                    "i" => {
530                        builder.case_insensitive(true);
531                    }
532                    "m" => {
533                        builder.multi_line(true);
534                    }
535                    "s" => {
536                        builder.dot_matches_new_line(true);
537                    }
538                    _ => {}
539                }
540            }
541            builder
542                .build()
543                .map_err(|e| anyhow::anyhow!("invalid regex pattern: {e}"))?;
544        }
545        Expected::JsonSchema {
546            json_schema,
547            schema_file,
548        } => {
549            let source = if let Some(path) = schema_file {
550                std::fs::read_to_string(path)
551                    .map_err(|e| anyhow::anyhow!("failed to read schema_file '{path}': {e}"))?
552            } else {
553                json_schema.clone()
554            };
555            let schema: serde_json::Value = serde_json::from_str(&source)
556                .map_err(|e| anyhow::anyhow!("invalid JSON schema: {e}"))?;
557            crate::policy_engine::compile_schema(&schema)
558                .map_err(|e| anyhow::anyhow!("schema compile failed: {e}"))?;
559        }
560        Expected::ArgsValid {
561            schema: Some(schema),
562            ..
563        } => validate_args_policy_value(schema)?,
564        Expected::ToolOutputValid {
565            schemas: Some(schema),
566        } => validate_schema_map(schema, false, true)?,
567        Expected::ArgsValid {
568            policy: Some(path),
569            schema: None,
570        } => validate_args_policy(path)?,
571        Expected::SequenceValid {
572            policy: Some(path), ..
573        } => validate_sequence_policy(path)?,
574        _ => {}
575    }
576    Ok(())
577}
578
579/// Replace file-backed assertion inputs with an immutable execution snapshot.
580///
581/// The returned `Expected` value is what validation, fingerprinting, and metric
582/// evaluation must all consume. This prevents incremental-cache drift and avoids
583/// a second file read after provider dispatch.
584pub(crate) fn bind_external_expected_inputs(e: &mut Expected) -> anyhow::Result<()> {
585    match e {
586        Expected::JsonSchema {
587            json_schema,
588            schema_file,
589        } => {
590            if let Some(path) = schema_file.take() {
591                *json_schema = std::fs::read_to_string(&path)
592                    .map_err(|err| anyhow::anyhow!("failed to read schema_file '{path}': {err}"))?;
593            }
594        }
595        Expected::ArgsValid { policy, schema } if schema.is_none() => {
596            if let Some(path) = policy.take() {
597                let source = std::fs::read_to_string(&path).map_err(|err| {
598                    anyhow::anyhow!("failed to read args_valid policy '{path}': {err}")
599                })?;
600                *schema = Some(
601                    serde_yaml::from_str(&source)
602                        .map_err(|err| anyhow::anyhow!("invalid args_valid policy YAML: {err}"))?,
603                );
604            }
605        }
606        Expected::SequenceValid {
607            policy,
608            sequence,
609            rules,
610        } => {
611            if let Some(path) = policy.take() {
612                let source = std::fs::read_to_string(&path).map_err(|err| {
613                    anyhow::anyhow!("failed to read sequence_valid policy '{path}': {err}")
614                })?;
615                if let Ok(loaded) = serde_yaml::from_str::<Vec<String>>(&source) {
616                    if sequence.is_none() {
617                        *sequence = Some(loaded);
618                    }
619                } else if let Ok(loaded) = serde_yaml::from_str::<super::types::Policy>(&source) {
620                    if rules.is_none() {
621                        *rules = Some(loaded.sequences);
622                    }
623                } else {
624                    let loaded =
625                        serde_yaml::from_str::<Vec<SequenceRule>>(&source).map_err(|err| {
626                            anyhow::anyhow!("invalid sequence_valid policy YAML: {err}")
627                        })?;
628                    if rules.is_none() {
629                        *rules = Some(loaded);
630                    }
631                }
632            }
633        }
634        _ => {}
635    }
636    Ok(())
637}
638
639fn validate_args_policy(path: &str) -> anyhow::Result<()> {
640    let source = std::fs::read_to_string(path)
641        .map_err(|e| anyhow::anyhow!("failed to read args_valid policy '{path}': {e}"))?;
642    let policy: serde_json::Value = serde_yaml::from_str(&source)
643        .map_err(|e| anyhow::anyhow!("invalid args_valid policy YAML: {e}"))?;
644
645    validate_args_policy_value(&policy)
646}
647
648/// Validate an inline `args_valid` policy using the execution-time contract.
649pub fn validate_args_policy_value(policy: &serde_json::Value) -> anyhow::Result<()> {
650    if policy
651        .as_object()
652        .and_then(|root| root.get("version"))
653        .is_some_and(|version| !version.is_string())
654    {
655        anyhow::bail!(
656            "args_valid policy version must be a string; move a legacy tool named `version` under `version: \"2.0\"` and `schemas.version`"
657        );
658    }
659    if policy
660        .as_object()
661        .is_some_and(|root| root.len() == 1 && root.contains_key("schemas"))
662    {
663        anyhow::bail!(
664            "args_valid policy with only `schemas` is ambiguous; add `version: \"2.0\"` for a structured policy, including a tool named `schemas`"
665        );
666    }
667
668    let structured = has_structured_args_policy_shape(policy);
669
670    if structured {
671        const UNENFORCED: &[&str] = &[
672            "constraints",
673            "limits",
674            "signatures",
675            "tool_pins",
676            "discovery",
677            "runtime_monitor",
678            "kill_switch",
679        ];
680        let unsupported: Vec<_> = UNENFORCED
681            .iter()
682            .copied()
683            .filter(|key| policy.get(*key).is_some())
684            .collect();
685        if !unsupported.is_empty() {
686            anyhow::bail!(
687                "args_valid policy fields are not enforced by this evaluator: {}",
688                unsupported.join(", ")
689            );
690        }
691
692        let mut allow = policy_string_list(policy.get("allow"), "allow")?;
693        let mut deny = policy_string_list(policy.get("deny"), "deny")?;
694        if let Some(tools) = policy.get("tools") {
695            let tools = tools
696                .as_object()
697                .ok_or_else(|| anyhow::anyhow!("args_valid policy tools must be a mapping"))?;
698            let mut unsupported: Vec<_> = tools
699                .keys()
700                .filter(|key| !matches!(key.as_str(), "allow" | "deny"))
701                .map(|key| format!("tools.{key}"))
702                .collect();
703            unsupported.sort_unstable();
704            if !unsupported.is_empty() {
705                anyhow::bail!(
706                    "args_valid policy fields are not enforced by this evaluator: {}",
707                    unsupported.join(", ")
708                );
709            }
710            allow.extend(policy_string_list(tools.get("allow"), "tools.allow")?);
711            deny.extend(policy_string_list(tools.get("deny"), "tools.deny")?);
712        }
713        let mut effective = !deny.is_empty()
714            || (!allow.is_empty()
715                && !allow
716                    .iter()
717                    .any(|pattern| is_universal_tool_pattern(pattern)));
718
719        if let Some(schemas) = policy.get("schemas") {
720            let schemas = schemas
721                .as_object()
722                .ok_or_else(|| anyhow::anyhow!("args_valid policy schemas must be a mapping"))?;
723            if !schemas.is_empty() {
724                let schemas = serde_json::Value::Object(schemas.clone());
725                let prepared_schemas = crate::policy_engine::prepare_schema_map(&schemas)
726                    .map_err(anyhow::Error::msg)?;
727                if !prepared_schemas
728                    .as_object()
729                    .is_some_and(serde_json::Map::is_empty)
730                {
731                    validate_schema_map(&prepared_schemas, false, false)?;
732                }
733                effective |= !schema_map_asserts_nothing(&schemas);
734            }
735        }
736
737        if let Some(enforcement) = policy.get("enforcement") {
738            let enforcement = enforcement.as_object().ok_or_else(|| {
739                anyhow::anyhow!("args_valid policy enforcement must be a mapping")
740            })?;
741            if let Some(mode) = enforcement.get("unconstrained_tools") {
742                let mode = mode.as_str().ok_or_else(|| {
743                    anyhow::anyhow!(
744                        "args_valid policy enforcement.unconstrained_tools must be a string"
745                    )
746                })?;
747                match mode {
748                    "deny" => effective = true,
749                    "warn" | "allow" => {}
750                    _ => anyhow::bail!(
751                        "args_valid policy enforcement.unconstrained_tools must be one of: warn, deny, allow"
752                    ),
753                }
754            }
755        }
756
757        if !effective {
758            anyhow::bail!("args_valid policy asserts nothing enforced by this evaluator");
759        }
760        return Ok(());
761    }
762
763    validate_schema_map(policy, true, true)
764}
765
766fn policy_string_list<'a>(
767    value: Option<&'a serde_json::Value>,
768    field: &str,
769) -> anyhow::Result<Vec<&'a str>> {
770    let Some(value) = value else {
771        return Ok(Vec::new());
772    };
773    let values = value
774        .as_array()
775        .ok_or_else(|| anyhow::anyhow!("args_valid policy {field} must be a list"))?;
776    values
777        .iter()
778        .map(|value| {
779            value
780                .as_str()
781                .ok_or_else(|| anyhow::anyhow!("args_valid policy {field} entries must be strings"))
782        })
783        .collect()
784}
785
786fn is_universal_tool_pattern(pattern: &str) -> bool {
787    !pattern.is_empty() && pattern.bytes().all(|byte| byte == b'*')
788}
789
790/// Return whether a value carries an unambiguous structured-policy discriminator.
791///
792/// `schemas` alone is intentionally not a discriminator: the same JSON shape can
793/// be a legacy schema map for a tool literally named `schemas`. Current policy
794/// documents identify themselves with `version: "2.0"` or another policy field.
795pub fn has_structured_args_policy_shape(policy: &serde_json::Value) -> bool {
796    let Some(root) = policy.as_object() else {
797        return false;
798    };
799    root.get("version")
800        .is_some_and(serde_json::Value::is_string)
801        || root.get("name").is_some_and(serde_json::Value::is_string)
802        || root.get("allow").is_some_and(serde_json::Value::is_array)
803        || root.get("deny").is_some_and(serde_json::Value::is_array)
804        || root
805            .get("tools")
806            .and_then(serde_json::Value::as_object)
807            .is_some_and(|tools| {
808                [
809                    "allow",
810                    "deny",
811                    "allow_classes",
812                    "deny_classes",
813                    "approval_required",
814                    "approval_required_classes",
815                    "restrict_scope",
816                    "restrict_scope_classes",
817                    "restrict_scope_contract",
818                    "redact_args",
819                    "redact_args_classes",
820                    "redact_args_contract",
821                ]
822                .iter()
823                .any(|key| tools.contains_key(*key))
824            })
825        || root
826            .get("enforcement")
827            .and_then(serde_json::Value::as_object)
828            .is_some_and(|enforcement| enforcement.contains_key("unconstrained_tools"))
829        || [
830            "constraints",
831            "limits",
832            "signatures",
833            "tool_pins",
834            "discovery",
835            "runtime_monitor",
836            "kill_switch",
837        ]
838        .iter()
839        .any(|key| root.contains_key(*key))
840}
841
842fn validate_sequence_policy(path: &str) -> anyhow::Result<()> {
843    let source = std::fs::read_to_string(path)
844        .map_err(|e| anyhow::anyhow!("failed to read sequence_valid policy '{path}': {e}"))?;
845    if serde_yaml::from_str::<Vec<String>>(&source).is_ok() {
846        return Ok(());
847    }
848
849    let rules = if let Ok(policy) = serde_yaml::from_str::<super::types::Policy>(&source) {
850        policy.sequences
851    } else {
852        serde_yaml::from_str::<Vec<SequenceRule>>(&source)
853            .map_err(|e| anyhow::anyhow!("invalid sequence_valid policy YAML: {e}"))?
854    };
855    let expected = Expected::SequenceValid {
856        policy: None,
857        sequence: None,
858        rules: Some(rules),
859    };
860    if let Some(field) = vacuous_expected_field(&expected) {
861        anyhow::bail!("`{field}` asserts nothing");
862    }
863    if let Some(reason) = non_executable_expected_reason(&expected) {
864        anyhow::bail!("expected block is not executable: {reason}");
865    }
866    if let Some(reason) = ineffective_expected_reason(&expected) {
867        anyhow::bail!("{reason}");
868    }
869    Ok(())
870}
871
872fn validate_schema_map(
873    value: &serde_json::Value,
874    reject_root_schema_keywords: bool,
875    require_effective_schema: bool,
876) -> anyhow::Result<()> {
877    let schemas = value
878        .as_object()
879        .filter(|schemas| !schemas.is_empty())
880        .ok_or_else(|| anyhow::anyhow!("schema must be a non-empty tool-name-to-schema map"))?;
881    if reject_root_schema_keywords && schemas.keys().any(|key| is_json_schema_keyword(key)) {
882        anyhow::bail!(
883            "root JSON Schema keywords cannot be used as tool names; expected a tool-name-to-schema map"
884        );
885    }
886    if require_effective_schema && schema_map_asserts_nothing(value) {
887        anyhow::bail!("schema map asserts nothing");
888    }
889    for (tool, schema) in schemas {
890        if !schema.is_object() && !schema.is_boolean() {
891            anyhow::bail!(
892                "schema entry '{tool}' must be a JSON Schema; expected a tool-name-to-schema map"
893            );
894        }
895        crate::policy_engine::compile_schema(schema)
896            .map_err(|e| anyhow::anyhow!("schema for tool '{tool}' failed to compile: {e}"))?;
897    }
898    Ok(())
899}
900
901fn is_json_schema_keyword(key: &str) -> bool {
902    matches!(
903        key,
904        "$schema"
905            | "$id"
906            | "$ref"
907            | "$defs"
908            | "$anchor"
909            | "$dynamicRef"
910            | "$dynamicAnchor"
911            | "$vocabulary"
912            | "$comment"
913            | "id"
914            | "definitions"
915            | "dependencies"
916            | "additionalItems"
917            | "$recursiveRef"
918            | "$recursiveAnchor"
919            | "divisibleBy"
920            | "disallow"
921            | "extends"
922            | "type"
923            | "enum"
924            | "const"
925            | "multipleOf"
926            | "maximum"
927            | "exclusiveMaximum"
928            | "minimum"
929            | "exclusiveMinimum"
930            | "maxLength"
931            | "minLength"
932            | "pattern"
933            | "items"
934            | "prefixItems"
935            | "contains"
936            | "maxItems"
937            | "minItems"
938            | "uniqueItems"
939            | "maxContains"
940            | "minContains"
941            | "properties"
942            | "patternProperties"
943            | "additionalProperties"
944            | "propertyNames"
945            | "maxProperties"
946            | "minProperties"
947            | "required"
948            | "dependentRequired"
949            | "dependentSchemas"
950            | "unevaluatedItems"
951            | "unevaluatedProperties"
952            | "allOf"
953            | "anyOf"
954            | "oneOf"
955            | "not"
956            | "if"
957            | "then"
958            | "else"
959            | "title"
960            | "description"
961            | "default"
962            | "deprecated"
963            | "readOnly"
964            | "writeOnly"
965            | "examples"
966            | "format"
967            | "contentEncoding"
968            | "contentMediaType"
969            | "contentSchema"
970    )
971}
972
973/// Validate the execution contract while preserving omitted-`expected` compatibility.
974pub(crate) fn validate_test_case_for_execution(test: &TestCase) -> anyhow::Result<()> {
975    // Deserialization represents an omitted `expected:` key with this exact default.
976    // A written empty block never reaches here because the parser rejects it. Keep
977    // the historical warning-only behavior until the assertion contract is tightened.
978    if matches!(
979        &test.expected,
980        Expected::MustContain { must_contain } if must_contain.is_empty()
981    ) {
982        return Ok(());
983    }
984    validate_expected_for_execution(&test.expected)
985}
986
987/// True for the legacy empty-`must_contain` sentinel.
988///
989/// `TestCase` does not retain whether this shape came from an omitted key or from
990/// programmatic construction, so serialization cannot distinguish those origins.
991pub(crate) fn is_omitted_expected_sentinel(e: &Expected) -> bool {
992    matches!(e, Expected::MustContain { must_contain } if must_contain.is_empty())
993}
994
995pub(crate) fn default_one() -> u32 {
996    1
997}
998
999pub(crate) fn default_min_score() -> f64 {
1000    0.80
1001}
1002
1003impl EvalConfig {
1004    pub fn is_legacy(&self) -> bool {
1005        self.version == 0
1006    }
1007
1008    pub fn has_legacy_usage(&self) -> bool {
1009        self.tests
1010            .iter()
1011            .any(|t: &TestCase| t.expected.get_policy_path().is_some())
1012    }
1013
1014    pub fn validate(&self) -> anyhow::Result<()> {
1015        if self.version >= 1 {
1016            for test in &self.tests {
1017                if matches!(test.expected, Expected::Reference { .. }) {
1018                    anyhow::bail!("$ref in expected block is not allowed in configVersion >= 1. Run `assay migrate` to inline policies.");
1019                }
1020            }
1021        }
1022        Ok(())
1023    }
1024
1025    /// Get the effective error policy for a test.
1026    /// Test-level on_error overrides suite-level settings.
1027    pub fn effective_error_policy(&self, test: &TestCase) -> ErrorPolicy {
1028        test.on_error.unwrap_or(self.settings.on_error)
1029    }
1030}
1031
1032impl Expected {
1033    pub fn get_policy_path(&self) -> Option<&str> {
1034        match self {
1035            Expected::ArgsValid { policy, .. } => policy.as_deref(),
1036            Expected::SequenceValid { policy, .. } => policy.as_deref(),
1037            _ => None,
1038        }
1039    }
1040
1041    /// Per-test thresholding for baseline regression (mode/max_drop) when this Expected variant matches the metric.
1042    pub fn thresholding_for_metric(&self, metric_name: &str) -> Option<&ThresholdingConfig> {
1043        match (metric_name, self) {
1044            ("semantic_similarity_to", Expected::SemanticSimilarityTo { thresholding, .. }) => {
1045                thresholding.as_ref()
1046            }
1047            ("faithfulness", Expected::Faithfulness { thresholding, .. }) => thresholding.as_ref(),
1048            ("relevance", Expected::Relevance { thresholding, .. }) => thresholding.as_ref(),
1049            _ => None,
1050        }
1051    }
1052}
1053
1054impl TestStatus {
1055    pub fn parse(s: &str) -> Self {
1056        match s {
1057            "pass" => TestStatus::Pass,
1058            "fail" => TestStatus::Fail,
1059            "flaky" => TestStatus::Flaky,
1060            "warn" => TestStatus::Warn,
1061            "error" => TestStatus::Error,
1062            "skipped" => TestStatus::Skipped,
1063            "unstable" => TestStatus::Unstable,
1064            "allowed_on_error" => TestStatus::AllowedOnError,
1065            _ => TestStatus::Error,
1066        }
1067    }
1068
1069    /// Returns true if this status should be treated as passing for CI purposes
1070    pub fn is_passing(&self) -> bool {
1071        matches!(
1072            self,
1073            TestStatus::Pass | TestStatus::AllowedOnError | TestStatus::Warn
1074        )
1075    }
1076
1077    /// Returns true if this status should block CI
1078    pub fn is_blocking(&self) -> bool {
1079        matches!(self, TestStatus::Fail | TestStatus::Error)
1080    }
1081}