Skip to main content

aidens_boundary_kit/
lib.rs

1//! Boundary compiler primitives for AiDENs.
2//!
3//! LLM structured output, patch payloads, generated manifests, and repaired
4//! JSON should pass through this crate before reaching tools or memory.
5//!
6//! Two compile paths are available:
7//!
8//! - `compile_json_boundary()` — the **lenient** path with repair, markdown
9//!   fence stripping, JSON substring extraction, and treatment-critical field
10//!   integrity checks.
11//!
12//! - `canonical_boundary::canonical_compile_json_boundary()` — the **strict**
13//!   path via the canonical `boundary-compiler-core` microkernel. Use this
14//!   when you need unforgiving RFC 8259 compliance with canonical digests.
15
16mod canonical_boundary;
17
18pub use boundary_compiler::{
19    BoundaryProfile, Canonicalizer, ContentDigest as CanonicalContentDigest,
20    JcsError as CanonicalJcsError,
21};
22
23pub use canonical_boundary::canonical_compile_json_boundary;
24
25use aidens_contracts::{
26    non_authoritative_json_display_digest, non_authoritative_text_display_digest, ArtifactId,
27    BoundaryCompileOutcomeV1, BoundaryCompileRequestV1, BoundaryRepairReportV1,
28    CanonicalBackpointerV1, DisplayDigestV1, DuplicateKeyFindingV1,
29    JsonBoundaryRepairDisplayReportV1, SchemaValidationReportV1,
30};
31use serde_json::Value;
32use std::collections::{BTreeMap, BTreeSet};
33use thiserror::Error;
34
35const MAX_JSON_BYTES: usize = 1_048_576;
36const MAX_JSON_DEPTH: usize = 64;
37const MAX_JSON_NODES: usize = 100_000;
38const MAX_JSON_STRING_BYTES: usize = 262_144;
39const MAX_JSON_ARRAY_ITEMS: usize = 50_000;
40const MAX_JSON_OBJECT_KEYS: usize = 50_000;
41
42#[derive(Debug, Error)]
43pub enum BoundaryError {
44    #[error("invalid json: {0}")]
45    InvalidJson(String),
46    #[error("duplicate json object keys: {0:?}")]
47    DuplicateKeys(Vec<DuplicateKeyFindingV1>),
48    #[error("json resource limit exceeded: {0}")]
49    ResourceLimit(String),
50}
51
52pub fn parse_strict_json(input: &str) -> Result<Value, BoundaryError> {
53    if input.len() > MAX_JSON_BYTES {
54        return Err(BoundaryError::ResourceLimit(format!(
55            "json-byte-length>{MAX_JSON_BYTES}"
56        )));
57    }
58    let duplicate_key_findings = detect_duplicate_keys(input)?;
59    if !duplicate_key_findings.is_empty() {
60        return Err(BoundaryError::DuplicateKeys(duplicate_key_findings));
61    }
62    let value = serde_json::from_str(input)
63        .map_err(|error| BoundaryError::InvalidJson(error.to_string()))?;
64    check_value_resource_limits(&value)?;
65    Ok(value)
66}
67
68/// Compute a canonical RFC 8785 JSON digest for any value.
69pub fn canonical_digest(
70    value: &serde_json::Value,
71) -> Result<boundary_compiler::ContentDigest, boundary_compiler::JcsError> {
72    boundary_compiler::ContentDigest::compute(value)
73}
74
75/// Canonicalize a JSON value per RFC 8785.
76pub fn canonicalize_json(value: &serde_json::Value) -> Result<String, boundary_compiler::JcsError> {
77    boundary_compiler::Canonicalizer::new().canonicalize(value)
78}
79
80/// Parse JSON with strict duplicate-key detection (using the canonical boundary compiler).
81pub fn parse_strict_canonical(
82    input: &str,
83) -> Result<serde_json::Value, boundary_compiler::JcsError> {
84    boundary_compiler::parse_with_dup_check(input)
85}
86
87#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
88pub struct BoundaryRepairPolicyV1 {
89    pub allow_markdown_fence_repair: bool,
90    pub allow_json_substring_extract: bool,
91}
92
93impl BoundaryRepairPolicyV1 {
94    pub fn permissive_degraded_repair() -> Self {
95        Self {
96            allow_markdown_fence_repair: true,
97            allow_json_substring_extract: true,
98        }
99    }
100}
101
102#[derive(Debug, Clone, PartialEq)]
103pub struct BoundaryParseOutcomeV1 {
104    pub value: Value,
105    pub repair_receipt: BoundaryRepairReportV1,
106    pub json_repair_receipt: Option<JsonBoundaryRepairDisplayReportV1>,
107}
108
109pub fn parse_json_boundary(
110    input: &str,
111    policy: BoundaryRepairPolicyV1,
112) -> Result<BoundaryParseOutcomeV1, BoundaryError> {
113    let mut request = BoundaryCompileRequestV1::new(input);
114    request.allow_markdown_fence_repair = policy.allow_markdown_fence_repair;
115    request.allow_json_substring_extract = policy.allow_json_substring_extract;
116
117    let outcome = compile_json_boundary(request);
118    if !outcome.accepted {
119        if !outcome.duplicate_key_findings.is_empty() {
120            return Err(BoundaryError::DuplicateKeys(outcome.duplicate_key_findings));
121        }
122        return Err(BoundaryError::InvalidJson(outcome.reason_codes.join(",")));
123    }
124
125    let value = outcome
126        .value
127        .ok_or_else(|| BoundaryError::InvalidJson("accepted outcome had no value".into()))?;
128    let repair_receipt = outcome
129        .repair_receipt
130        .as_ref()
131        .map(BoundaryRepairReportV1::from)
132        .unwrap_or_else(no_repair_receipt);
133    Ok(BoundaryParseOutcomeV1 {
134        value,
135        repair_receipt,
136        json_repair_receipt: outcome.repair_receipt,
137    })
138}
139
140pub fn compile_json_boundary(request: BoundaryCompileRequestV1) -> BoundaryCompileOutcomeV1 {
141    match parse_strict_json(&request.input) {
142        Ok(value) => finalize_compile_outcome(request, value, None),
143        Err(BoundaryError::DuplicateKeys(findings)) => {
144            duplicate_key_outcome(request.request_id, findings, None)
145        }
146        Err(BoundaryError::ResourceLimit(limit)) => BoundaryCompileOutcomeV1::rejected(
147            request.request_id,
148            vec!["boundary-resource-limit-exceeded".into(), limit],
149        ),
150        Err(original_error) => compile_with_repair(request, original_error),
151    }
152}
153
154pub fn validate_json_schema(schema: &Value, input: &Value) -> SchemaValidationReportV1 {
155    let mut errors = Vec::new();
156    validate_schema_at("$", schema, schema, input, &mut errors, &mut Vec::new());
157    SchemaValidationReportV1::new(Some(schema), input, errors)
158}
159
160pub fn validate_tool_input_schema(
161    tool_id: &str,
162    schema: &Value,
163    input: &Value,
164) -> SchemaValidationReportV1 {
165    validate_json_schema(schema, input).with_tool_id(tool_id)
166}
167
168pub fn memory_claim_input_schema() -> Value {
169    serde_json::json!({
170        "type": "object",
171        "additionalProperties": false,
172        "required": ["subject", "predicate", "object", "valid_from"],
173        "properties": {
174            "subject": { "type": "string" },
175            "predicate": { "type": "string" },
176            "object": {},
177            "valid_from": { "type": "string" },
178            "valid_to": { "type": "string" },
179            "evidence": { "type": "object" },
180            "recorded_at": { "type": "string" }
181        }
182    })
183}
184
185pub fn validate_memory_claim_input(input: &Value) -> SchemaValidationReportV1 {
186    validate_json_schema(&memory_claim_input_schema(), input).with_tool_id("aidens:memory-write:1")
187}
188
189pub fn detect_duplicate_keys(input: &str) -> Result<Vec<DuplicateKeyFindingV1>, BoundaryError> {
190    let mut scanner = JsonDuplicateScanner::new(input);
191    scanner.scan()
192}
193
194fn compile_with_repair(
195    request: BoundaryCompileRequestV1,
196    original_error: BoundaryError,
197) -> BoundaryCompileOutcomeV1 {
198    if request.allow_markdown_fence_repair {
199        if let Some(repaired) = strip_markdown_json_fence(&request.input) {
200            return compile_repaired_candidate(
201                request,
202                "markdown-json-fence-stripped",
203                repaired,
204                vec!["removed markdown code fence before JSON boundary parse".into()],
205            );
206        }
207    }
208
209    if request.allow_json_substring_extract {
210        if let Some(extracted) = extract_first_json_value(&request.input) {
211            return compile_repaired_candidate(
212                request,
213                "json-substring-extracted",
214                extracted,
215                vec!["extracted first JSON object or array before boundary parse".into()],
216            );
217        }
218    }
219
220    BoundaryCompileOutcomeV1::rejected(
221        request.request_id,
222        vec![
223            "invalid-json".into(),
224            format!("boundary-parse-failed:{original_error}"),
225        ],
226    )
227}
228
229fn compile_repaired_candidate(
230    request: BoundaryCompileRequestV1,
231    repair_kind: &str,
232    repaired: String,
233    warnings: Vec<String>,
234) -> BoundaryCompileOutcomeV1 {
235    let original = request.input.clone();
236    match parse_strict_json(&repaired) {
237        Ok(value) => {
238            let mut repair = json_repair_receipt(
239                repair_kind,
240                &original,
241                &repaired,
242                None,
243                Some(&value),
244                &request.treatment_critical_fields,
245                warnings,
246            );
247            if !repair.treatment_integrity_warnings.is_empty()
248                && request.hard_fail_on_treatment_change
249            {
250                repair.hard_failed = true;
251                repair
252                    .reason_codes
253                    .push("treatment-integrity-hard-fail".into());
254                let mut outcome = BoundaryCompileOutcomeV1::rejected(
255                    request.request_id,
256                    vec!["treatment-integrity-hard-fail".into()],
257                );
258                outcome.value = Some(value.clone());
259                outcome.display_digest = Some(DisplayDigestV1::for_json_value(&value));
260                outcome.repair_receipt = Some(repair);
261                return outcome;
262            }
263            finalize_compile_outcome(request, value, Some(repair))
264        }
265        Err(BoundaryError::DuplicateKeys(findings)) => {
266            let repair = json_repair_receipt(
267                repair_kind,
268                &original,
269                &repaired,
270                None,
271                None,
272                &request.treatment_critical_fields,
273                warnings,
274            );
275            duplicate_key_outcome(request.request_id, findings, Some(repair))
276        }
277        Err(error) => {
278            let mut outcome = BoundaryCompileOutcomeV1::rejected(
279                request.request_id,
280                vec![
281                    "repair-produced-invalid-json".into(),
282                    format!("boundary-parse-failed:{error}"),
283                ],
284            );
285            outcome.repair_receipt = Some(json_repair_receipt(
286                repair_kind,
287                &original,
288                &repaired,
289                None,
290                None,
291                &request.treatment_critical_fields,
292                warnings,
293            ));
294            outcome
295        }
296    }
297}
298
299fn finalize_compile_outcome(
300    request: BoundaryCompileRequestV1,
301    value: Value,
302    repair_receipt: Option<JsonBoundaryRepairDisplayReportV1>,
303) -> BoundaryCompileOutcomeV1 {
304    let schema_validation = request
305        .schema
306        .as_ref()
307        .map(|schema| validate_json_schema(schema, &value));
308    let schema_valid = schema_validation
309        .as_ref()
310        .map(|receipt| receipt.valid)
311        .unwrap_or(true);
312    let mut reason_codes = Vec::new();
313    if schema_valid {
314        reason_codes.push("boundary-compile-accepted".into());
315    } else {
316        reason_codes.push("schema-validation-failed".into());
317    }
318    if let Some(repair) = &repair_receipt {
319        reason_codes.push(format!("boundary-repair:{}", repair.repair_kind));
320        reason_codes.extend(repair.reason_codes.iter().cloned());
321    }
322    reason_codes.sort();
323    reason_codes.dedup();
324
325    BoundaryCompileOutcomeV1 {
326        outcome_id: ArtifactId::new("boundary-compile-outcome"),
327        request_id: request.request_id,
328        accepted: schema_valid,
329        degraded: !schema_valid
330            || repair_receipt
331                .as_ref()
332                .map(|receipt| receipt.degraded)
333                .unwrap_or(false),
334        value: Some(value.clone()),
335        display_digest: Some(DisplayDigestV1::for_json_value(&value)),
336        duplicate_key_findings: Vec::new(),
337        schema_validation,
338        repair_receipt,
339        reason_codes,
340        compiled_at: chrono::Utc::now(),
341    }
342}
343
344fn duplicate_key_outcome(
345    request_id: ArtifactId,
346    findings: Vec<DuplicateKeyFindingV1>,
347    repair_receipt: Option<JsonBoundaryRepairDisplayReportV1>,
348) -> BoundaryCompileOutcomeV1 {
349    BoundaryCompileOutcomeV1 {
350        outcome_id: ArtifactId::new("boundary-compile-outcome"),
351        request_id,
352        accepted: false,
353        degraded: true,
354        value: None,
355        display_digest: None,
356        duplicate_key_findings: findings,
357        schema_validation: None,
358        repair_receipt,
359        reason_codes: vec!["duplicate-json-object-key".into()],
360        compiled_at: chrono::Utc::now(),
361    }
362}
363
364fn check_value_resource_limits(value: &Value) -> Result<(), BoundaryError> {
365    let mut stats = ValueResourceStats::default();
366    collect_value_resource_stats(value, 0, &mut stats)?;
367    if stats.nodes > MAX_JSON_NODES {
368        return Err(BoundaryError::ResourceLimit(format!(
369            "json-node-count>{MAX_JSON_NODES}"
370        )));
371    }
372    Ok(())
373}
374
375#[derive(Default)]
376struct ValueResourceStats {
377    nodes: usize,
378}
379
380fn collect_value_resource_stats(
381    value: &Value,
382    depth: usize,
383    stats: &mut ValueResourceStats,
384) -> Result<(), BoundaryError> {
385    if depth > MAX_JSON_DEPTH {
386        return Err(BoundaryError::ResourceLimit(format!(
387            "json-depth>{MAX_JSON_DEPTH}"
388        )));
389    }
390    stats.nodes += 1;
391    match value {
392        Value::Array(items) => {
393            if items.len() > MAX_JSON_ARRAY_ITEMS {
394                return Err(BoundaryError::ResourceLimit(format!(
395                    "json-array-items>{MAX_JSON_ARRAY_ITEMS}"
396                )));
397            }
398            for item in items {
399                collect_value_resource_stats(item, depth + 1, stats)?;
400            }
401        }
402        Value::Object(map) => {
403            if map.len() > MAX_JSON_OBJECT_KEYS {
404                return Err(BoundaryError::ResourceLimit(format!(
405                    "json-object-keys>{MAX_JSON_OBJECT_KEYS}"
406                )));
407            }
408            for item in map.values() {
409                collect_value_resource_stats(item, depth + 1, stats)?;
410            }
411        }
412        Value::String(text) => {
413            if text.len() > MAX_JSON_STRING_BYTES {
414                return Err(BoundaryError::ResourceLimit(format!(
415                    "json-string-bytes>{MAX_JSON_STRING_BYTES}"
416                )));
417            }
418        }
419        Value::Bool(_) | Value::Null | Value::Number(_) => {}
420    }
421    Ok(())
422}
423
424fn json_repair_receipt(
425    repair_kind: &str,
426    before: &str,
427    after: &str,
428    before_value: Option<&Value>,
429    after_value: Option<&Value>,
430    treatment_critical_fields: &[String],
431    warnings: Vec<String>,
432) -> JsonBoundaryRepairDisplayReportV1 {
433    let treatment_integrity_warnings =
434        treatment_integrity_warnings(after_value, treatment_critical_fields, repair_kind);
435    let mut reason_codes = vec![format!("json-repair:{repair_kind}")];
436    if !treatment_integrity_warnings.is_empty() {
437        reason_codes.push("treatment-integrity-unverifiable".into());
438    }
439    JsonBoundaryRepairDisplayReportV1 {
440        receipt_id: ArtifactId::new("json-repair"),
441        kind: aidens_contracts::ArtifactKindV1::BoundaryRepair,
442        changed: before != after,
443        repair_kind: repair_kind.into(),
444        degraded: before != after,
445        before_raw_digest: Some(non_authoritative_text_display_digest(before)),
446        after_raw_digest: Some(non_authoritative_text_display_digest(after)),
447        before_display_digest: before_value.map(DisplayDigestV1::for_json_value),
448        after_display_digest: after_value.map(DisplayDigestV1::for_json_value),
449        treatment_critical_fields: treatment_critical_fields.to_vec(),
450        treatment_integrity_warnings,
451        hard_failed: false,
452        warnings,
453        reason_codes,
454        canonical_repair_record_ids: Vec::new(),
455        canonical_backpointers: vec![CanonicalBackpointerV1::owner_type(
456            "verification-control",
457            "BoundaryRepairRecord",
458            "canonical-boundary-repair-owner",
459        )],
460    }
461}
462
463fn treatment_integrity_warnings(
464    after_value: Option<&Value>,
465    treatment_critical_fields: &[String],
466    repair_kind: &str,
467) -> Vec<String> {
468    let Some(after_value) = after_value else {
469        return Vec::new();
470    };
471    treatment_critical_fields
472        .iter()
473        .filter(|field| lookup_treatment_field(after_value, field).is_some())
474        .map(|field| format!("treatment-integrity-unverifiable:{repair_kind}:{field}"))
475        .collect()
476}
477
478fn lookup_treatment_field<'a>(value: &'a Value, field: &str) -> Option<&'a Value> {
479    if field.starts_with('/') {
480        return value.pointer(field);
481    }
482    let mut current = value;
483    for segment in field.split('.') {
484        current = current.get(segment)?;
485    }
486    Some(current)
487}
488
489fn validate_schema_at(
490    path: &str,
491    root_schema: &Value,
492    schema: &Value,
493    input: &Value,
494    errors: &mut Vec<String>,
495    ref_stack: &mut Vec<String>,
496) {
497    let Some(schema_object) = schema.as_object() else {
498        errors.push(format!("{path}: schema must be a JSON object"));
499        return;
500    };
501    for keyword in schema_object.keys() {
502        if !SUPPORTED_SCHEMA_KEYWORDS.contains(&keyword.as_str()) {
503            errors.push(format!("{path}: unsupported schema keyword '{keyword}'"));
504        }
505    }
506
507    if let Some(reference) = schema_object.get("$ref") {
508        let Some(reference) = reference.as_str() else {
509            errors.push(format!("{path}: $ref must be a string"));
510            return;
511        };
512        if ref_stack.iter().any(|seen| seen == reference) {
513            errors.push(format!(
514                "{path}: recursive $ref '{reference}' is unsupported"
515            ));
516            return;
517        }
518        let Some(resolved) = resolve_local_schema_ref(root_schema, reference) else {
519            errors.push(format!(
520                "{path}: unsupported or unresolved $ref '{reference}'"
521            ));
522            return;
523        };
524        ref_stack.push(reference.to_string());
525        validate_schema_at(path, root_schema, resolved, input, errors, ref_stack);
526        ref_stack.pop();
527    }
528
529    if let Some(any_of) = schema_object.get("anyOf") {
530        validate_any_of(path, root_schema, any_of, input, errors, ref_stack);
531    }
532
533    if let Some(one_of) = schema_object.get("oneOf") {
534        validate_one_of(path, root_schema, one_of, input, errors, ref_stack);
535    }
536
537    if let Some(all_of) = schema_object.get("allOf") {
538        validate_all_of(path, root_schema, all_of, input, errors, ref_stack);
539    }
540
541    if let Some(type_value) = schema_object.get("type") {
542        let expected = expected_types(type_value);
543        if expected.is_empty() {
544            errors.push(format!(
545                "{path}: schema type must be string or array of strings"
546            ));
547        } else if !expected
548            .iter()
549            .any(|expected| value_matches_type(input, expected))
550        {
551            errors.push(format!(
552                "{path}: expected type {}, got {}",
553                expected.join("|"),
554                value_type(input)
555            ));
556            return;
557        }
558    }
559
560    if let Some(enum_values) = schema_object.get("enum") {
561        let Some(items) = enum_values.as_array() else {
562            errors.push(format!("{path}: enum must be an array"));
563            return;
564        };
565        if !items.iter().any(|item| item == input) {
566            errors.push(format!("{path}: value is not in enum"));
567        }
568    }
569
570    if let Some(format_value) = schema_object.get("format") {
571        validate_format(path, format_value, input, errors);
572    }
573
574    if let Some(minimum) = schema_object.get("minimum") {
575        let Some(minimum) = minimum.as_f64() else {
576            errors.push(format!("{path}: minimum must be numeric"));
577            return;
578        };
579        if input.as_f64().is_some_and(|value| value < minimum) {
580            errors.push(format!("{path}: value is below minimum {minimum}"));
581        }
582    }
583
584    if let Some(maximum) = schema_object.get("maximum") {
585        let Some(maximum) = maximum.as_f64() else {
586            errors.push(format!("{path}: maximum must be numeric"));
587            return;
588        };
589        if input.as_f64().is_some_and(|value| value > maximum) {
590            errors.push(format!("{path}: value is above maximum {maximum}"));
591        }
592    }
593
594    if let Some(expected) = schema_object.get("const") {
595        if input != expected {
596            errors.push(format!("{path}: value does not match const"));
597        }
598    }
599
600    if let Some(min_length) = schema_object.get("minLength") {
601        let Some(min_length) = min_length.as_u64() else {
602            errors.push(format!("{path}: minLength must be an integer"));
603            return;
604        };
605        if input
606            .as_str()
607            .is_some_and(|value| value.chars().count() < min_length as usize)
608        {
609            errors.push(format!(
610                "{path}: string length is below minLength {min_length}"
611            ));
612        }
613    }
614
615    if let Some(max_length) = schema_object.get("maxLength") {
616        let Some(max_length) = max_length.as_u64() else {
617            errors.push(format!("{path}: maxLength must be an integer"));
618            return;
619        };
620        if input
621            .as_str()
622            .is_some_and(|value| value.chars().count() > max_length as usize)
623        {
624            errors.push(format!(
625                "{path}: string length is above maxLength {max_length}"
626            ));
627        }
628    }
629
630    if let Some(input_array) = input.as_array() {
631        if let Some(min_items) = schema_object.get("minItems") {
632            let Some(min_items) = min_items.as_u64() else {
633                errors.push(format!("{path}: minItems must be an integer"));
634                return;
635            };
636            if input_array.len() < min_items as usize {
637                errors.push(format!(
638                    "{path}: array length is below minItems {min_items}"
639                ));
640            }
641        }
642        if let Some(max_items) = schema_object.get("maxItems") {
643            let Some(max_items) = max_items.as_u64() else {
644                errors.push(format!("{path}: maxItems must be an integer"));
645                return;
646            };
647            if input_array.len() > max_items as usize {
648                errors.push(format!(
649                    "{path}: array length is above maxItems {max_items}"
650                ));
651            }
652        }
653        if let Some(item_schema) = schema_object.get("items") {
654            for (index, item) in input_array.iter().enumerate() {
655                validate_schema_at(
656                    &format!("{path}[{index}]"),
657                    root_schema,
658                    item_schema,
659                    item,
660                    errors,
661                    ref_stack,
662                );
663            }
664        }
665    }
666
667    let Some(input_object) = input.as_object() else {
668        return;
669    };
670
671    if let Some(required) = schema_object.get("required") {
672        match required.as_array() {
673            Some(required) => {
674                for field in required {
675                    let Some(field) = field.as_str() else {
676                        errors.push(format!("{path}: required entries must be strings"));
677                        continue;
678                    };
679                    if !input_object.contains_key(field) {
680                        errors.push(format!("{path}: missing required property '{field}'"));
681                    }
682                }
683            }
684            None => errors.push(format!("{path}: required must be an array")),
685        }
686    }
687
688    let properties = match schema_object.get("properties") {
689        Some(properties) => match properties.as_object() {
690            Some(properties) => properties.clone(),
691            None => {
692                errors.push(format!("{path}: properties must be an object"));
693                serde_json::Map::new()
694            }
695        },
696        None => serde_json::Map::new(),
697    };
698
699    for (field, child_schema) in &properties {
700        if let Some(child) = input_object.get(field) {
701            validate_schema_at(
702                &object_child_path(path, field),
703                root_schema,
704                child_schema,
705                child,
706                errors,
707                ref_stack,
708            );
709        }
710    }
711
712    if let Some(additional_properties) = schema_object.get("additionalProperties") {
713        let allowed = properties.keys().cloned().collect::<BTreeSet<_>>();
714        match additional_properties {
715            Value::Bool(false) => {
716                for field in input_object.keys() {
717                    if !allowed.contains(field) {
718                        errors.push(format!(
719                            "{path}: additional property '{field}' is not allowed"
720                        ));
721                    }
722                }
723            }
724            Value::Bool(true) => {}
725            Value::Object(_) => {
726                for (field, child) in input_object {
727                    if !allowed.contains(field) {
728                        validate_schema_at(
729                            &object_child_path(path, field),
730                            root_schema,
731                            additional_properties,
732                            child,
733                            errors,
734                            ref_stack,
735                        );
736                    }
737                }
738            }
739            _ => errors.push(format!(
740                "{path}: additionalProperties must be boolean or object"
741            )),
742        }
743    }
744}
745
746const SUPPORTED_SCHEMA_KEYWORDS: &[&str] = &[
747    "$defs",
748    "$ref",
749    "$schema",
750    "additionalProperties",
751    "allOf",
752    "anyOf",
753    "const",
754    "default",
755    "definitions",
756    "description",
757    "enum",
758    "format",
759    "items",
760    "maximum",
761    "maxItems",
762    "maxLength",
763    "minimum",
764    "minItems",
765    "minLength",
766    "properties",
767    "required",
768    "title",
769    "type",
770    "oneOf",
771];
772
773fn resolve_local_schema_ref<'a>(root_schema: &'a Value, reference: &str) -> Option<&'a Value> {
774    let pointer = reference.strip_prefix('#')?;
775    if pointer.is_empty() {
776        return Some(root_schema);
777    }
778    if !pointer.starts_with('/') {
779        return None;
780    }
781    root_schema.pointer(pointer)
782}
783
784fn validate_any_of(
785    path: &str,
786    root_schema: &Value,
787    schemas: &Value,
788    input: &Value,
789    errors: &mut Vec<String>,
790    ref_stack: &[String],
791) {
792    let Some(schemas) = schemas.as_array() else {
793        errors.push(format!("{path}: anyOf must be an array"));
794        return;
795    };
796    if schemas.is_empty() {
797        errors.push(format!("{path}: anyOf must not be empty"));
798        return;
799    }
800    let mut matched = false;
801    for schema in schemas {
802        let mut branch_errors = Vec::new();
803        let mut branch_ref_stack = ref_stack.to_vec();
804        validate_schema_at(
805            path,
806            root_schema,
807            schema,
808            input,
809            &mut branch_errors,
810            &mut branch_ref_stack,
811        );
812        if branch_errors.is_empty() {
813            matched = true;
814            break;
815        }
816    }
817    if !matched {
818        errors.push(format!("{path}: value does not match anyOf"));
819    }
820}
821
822fn validate_one_of(
823    path: &str,
824    root_schema: &Value,
825    schemas: &Value,
826    input: &Value,
827    errors: &mut Vec<String>,
828    ref_stack: &[String],
829) {
830    let Some(schemas) = schemas.as_array() else {
831        errors.push(format!("{path}: oneOf must be an array"));
832        return;
833    };
834    if schemas.is_empty() {
835        errors.push(format!("{path}: oneOf must not be empty"));
836        return;
837    }
838    let mut matches = 0usize;
839    for schema in schemas {
840        let mut branch_errors = Vec::new();
841        let mut branch_ref_stack = ref_stack.to_vec();
842        validate_schema_at(
843            path,
844            root_schema,
845            schema,
846            input,
847            &mut branch_errors,
848            &mut branch_ref_stack,
849        );
850        if branch_errors.is_empty() {
851            matches += 1;
852        }
853    }
854    if matches != 1 {
855        errors.push(format!(
856            "{path}: value matched {matches} oneOf branches, expected exactly 1"
857        ));
858    }
859}
860
861fn validate_all_of(
862    path: &str,
863    root_schema: &Value,
864    schemas: &Value,
865    input: &Value,
866    errors: &mut Vec<String>,
867    ref_stack: &mut Vec<String>,
868) {
869    let Some(schemas) = schemas.as_array() else {
870        errors.push(format!("{path}: allOf must be an array"));
871        return;
872    };
873    if schemas.is_empty() {
874        errors.push(format!("{path}: allOf must not be empty"));
875        return;
876    }
877    for schema in schemas {
878        validate_schema_at(path, root_schema, schema, input, errors, ref_stack);
879    }
880}
881
882fn validate_format(path: &str, format_value: &Value, input: &Value, errors: &mut Vec<String>) {
883    let Some(format) = format_value.as_str() else {
884        errors.push(format!("{path}: format must be a string"));
885        return;
886    };
887    match format {
888        "date-time" => {
889            if let Some(text) = input.as_str() {
890                if chrono::DateTime::parse_from_rfc3339(text).is_err() {
891                    errors.push(format!("{path}: string is not valid date-time"));
892                }
893            }
894        }
895        "uint" | "uint32" | "uint64" => {
896            if let Some(value) = input.as_i64() {
897                if value < 0 {
898                    errors.push(format!("{path}: integer is negative for {format}"));
899                }
900            }
901            if format == "uint32" && input.as_u64().is_some_and(|value| value > u32::MAX as u64) {
902                errors.push(format!("{path}: integer is above uint32"));
903            }
904        }
905        "int32" => {
906            if input
907                .as_i64()
908                .is_some_and(|value| value < i32::MIN as i64 || value > i32::MAX as i64)
909            {
910                errors.push(format!("{path}: integer is outside int32"));
911            }
912        }
913        "int64" => {}
914        other => errors.push(format!("{path}: unsupported schema format '{other}'")),
915    }
916}
917
918fn expected_types(type_value: &Value) -> Vec<String> {
919    if let Some(type_name) = type_value.as_str() {
920        return vec![type_name.into()];
921    }
922    type_value
923        .as_array()
924        .into_iter()
925        .flatten()
926        .filter_map(Value::as_str)
927        .map(str::to_string)
928        .collect()
929}
930
931fn value_matches_type(value: &Value, expected: &str) -> bool {
932    match expected {
933        "array" => value.is_array(),
934        "boolean" => value.is_boolean(),
935        "integer" => value.as_i64().is_some() || value.as_u64().is_some(),
936        "null" => value.is_null(),
937        "number" => value.is_number(),
938        "object" => value.is_object(),
939        "string" => value.is_string(),
940        _ => false,
941    }
942}
943
944fn value_type(value: &Value) -> &'static str {
945    match value {
946        Value::Array(_) => "array",
947        Value::Bool(_) => "boolean",
948        Value::Null => "null",
949        Value::Number(number) if number.is_i64() || number.is_u64() => "integer",
950        Value::Number(_) => "number",
951        Value::Object(_) => "object",
952        Value::String(_) => "string",
953    }
954}
955
956fn strip_markdown_json_fence(input: &str) -> Option<String> {
957    let trimmed = input.trim();
958    let body = trimmed
959        .strip_prefix("```json")
960        .or_else(|| trimmed.strip_prefix("```"))?;
961    let body = body.strip_suffix("```")?;
962    Some(body.trim().to_string())
963}
964
965fn extract_first_json_value(input: &str) -> Option<String> {
966    let mut starts = [
967        input.find('{').map(|idx| (idx, '{', '}')),
968        input.find('[').map(|idx| (idx, '[', ']')),
969    ]
970    .into_iter()
971    .flatten()
972    .collect::<Vec<_>>();
973    starts.sort_by_key(|(idx, _, _)| *idx);
974    for (start, open, close) in starts {
975        let Some(end) = find_matching_delimiter(&input[start..], open, close) else {
976            continue;
977        };
978        return Some(input[start..=start + end].trim().to_string());
979    }
980    None
981}
982
983fn find_matching_delimiter(input: &str, open: char, close: char) -> Option<usize> {
984    let mut depth = 0usize;
985    let mut in_string = false;
986    let mut escaped = false;
987    for (idx, ch) in input.char_indices() {
988        if in_string {
989            if escaped {
990                escaped = false;
991            } else if ch == '\\' {
992                escaped = true;
993            } else if ch == '"' {
994                in_string = false;
995            }
996            continue;
997        }
998        match ch {
999            '"' => in_string = true,
1000            c if c == open => depth += 1,
1001            c if c == close => {
1002                depth = depth.saturating_sub(1);
1003                if depth == 0 {
1004                    return Some(idx);
1005                }
1006            }
1007            _ => {}
1008        }
1009    }
1010    None
1011}
1012
1013pub fn non_authoritative_boundary_json_display_digest(value: &Value) -> String {
1014    non_authoritative_json_display_digest(value)
1015}
1016
1017fn text_digest(text: &str) -> String {
1018    non_authoritative_text_display_digest(text)
1019}
1020
1021pub fn no_repair_receipt() -> BoundaryRepairReportV1 {
1022    BoundaryRepairReportV1 {
1023        receipt_id: ArtifactId::new("boundary-repair"),
1024        changed: false,
1025        repair_kind: "none".into(),
1026        before_digest: None,
1027        after_digest: None,
1028        canonical_repair_record_ids: Vec::new(),
1029        canonical_backpointers: vec![CanonicalBackpointerV1::owner_type(
1030            "verification-control",
1031            "BoundaryRepairRecord",
1032            "canonical-boundary-repair-owner",
1033        )],
1034        warnings: Vec::new(),
1035    }
1036}
1037
1038pub fn repair_receipt(
1039    repair_kind: impl Into<String>,
1040    before: &str,
1041    after: &str,
1042    warnings: Vec<String>,
1043) -> BoundaryRepairReportV1 {
1044    BoundaryRepairReportV1 {
1045        receipt_id: ArtifactId::new("boundary-repair"),
1046        changed: before != after,
1047        repair_kind: repair_kind.into(),
1048        before_digest: Some(text_digest(before)),
1049        after_digest: Some(text_digest(after)),
1050        canonical_repair_record_ids: Vec::new(),
1051        canonical_backpointers: vec![CanonicalBackpointerV1::owner_type(
1052            "verification-control",
1053            "BoundaryRepairRecord",
1054            "canonical-boundary-repair-owner",
1055        )],
1056        warnings,
1057    }
1058}
1059
1060struct JsonDuplicateScanner<'a> {
1061    input: &'a str,
1062    bytes: &'a [u8],
1063    pos: usize,
1064    node_count: usize,
1065    findings: Vec<DuplicateKeyFindingV1>,
1066}
1067
1068impl<'a> JsonDuplicateScanner<'a> {
1069    fn new(input: &'a str) -> Self {
1070        Self {
1071            input,
1072            bytes: input.as_bytes(),
1073            pos: 0,
1074            node_count: 0,
1075            findings: Vec::new(),
1076        }
1077    }
1078
1079    fn scan(&mut self) -> Result<Vec<DuplicateKeyFindingV1>, BoundaryError> {
1080        self.skip_ws();
1081        self.parse_value("$", 0)?;
1082        self.skip_ws();
1083        if self.pos != self.bytes.len() {
1084            return Err(self.invalid("trailing bytes after JSON value"));
1085        }
1086        Ok(std::mem::take(&mut self.findings))
1087    }
1088
1089    fn parse_value(&mut self, path: &str, depth: usize) -> Result<(), BoundaryError> {
1090        if depth > MAX_JSON_DEPTH {
1091            return Err(BoundaryError::ResourceLimit(format!(
1092                "json-depth>{MAX_JSON_DEPTH}"
1093            )));
1094        }
1095        self.node_count += 1;
1096        if self.node_count > MAX_JSON_NODES {
1097            return Err(BoundaryError::ResourceLimit(format!(
1098                "json-node-count>{MAX_JSON_NODES}"
1099            )));
1100        }
1101        self.skip_ws();
1102        let Some(byte) = self.peek() else {
1103            return Err(self.invalid("unexpected end of input"));
1104        };
1105        match byte {
1106            b'{' => self.parse_object(path, depth),
1107            b'[' => self.parse_array(path, depth),
1108            b'"' => self.parse_string().map(|_| ()),
1109            b't' => self.parse_literal(b"true"),
1110            b'f' => self.parse_literal(b"false"),
1111            b'n' => self.parse_literal(b"null"),
1112            b'-' | b'0'..=b'9' => self.parse_number(),
1113            _ => Err(self.invalid("unexpected byte while parsing value")),
1114        }
1115    }
1116
1117    fn parse_object(&mut self, path: &str, depth: usize) -> Result<(), BoundaryError> {
1118        self.expect(b'{')?;
1119        self.skip_ws();
1120        if self.consume(b'}') {
1121            return Ok(());
1122        }
1123
1124        let mut keys = BTreeMap::<String, usize>::new();
1125        let mut key_count = 0usize;
1126        loop {
1127            self.skip_ws();
1128            let (key, key_offset) = self.parse_string()?;
1129            key_count += 1;
1130            if key_count > MAX_JSON_OBJECT_KEYS {
1131                return Err(BoundaryError::ResourceLimit(format!(
1132                    "json-object-keys>{MAX_JSON_OBJECT_KEYS}"
1133                )));
1134            }
1135            if let Some(first_offset) = keys.insert(key.clone(), key_offset) {
1136                self.findings.push(DuplicateKeyFindingV1::new(
1137                    path,
1138                    key.clone(),
1139                    Some(first_offset),
1140                    Some(key_offset),
1141                ));
1142            }
1143            self.skip_ws();
1144            self.expect(b':')?;
1145            let child_path = object_child_path(path, &key);
1146            self.parse_value(&child_path, depth + 1)?;
1147            self.skip_ws();
1148            if self.consume(b'}') {
1149                break;
1150            }
1151            self.expect(b',')?;
1152        }
1153        Ok(())
1154    }
1155
1156    fn parse_array(&mut self, path: &str, depth: usize) -> Result<(), BoundaryError> {
1157        self.expect(b'[')?;
1158        self.skip_ws();
1159        if self.consume(b']') {
1160            return Ok(());
1161        }
1162
1163        let mut index = 0usize;
1164        loop {
1165            if index >= MAX_JSON_ARRAY_ITEMS {
1166                return Err(BoundaryError::ResourceLimit(format!(
1167                    "json-array-items>{MAX_JSON_ARRAY_ITEMS}"
1168                )));
1169            }
1170            self.parse_value(&format!("{path}[{index}]"), depth + 1)?;
1171            index += 1;
1172            self.skip_ws();
1173            if self.consume(b']') {
1174                break;
1175            }
1176            self.expect(b',')?;
1177        }
1178        Ok(())
1179    }
1180
1181    fn parse_string(&mut self) -> Result<(String, usize), BoundaryError> {
1182        let start = self.pos;
1183        self.expect(b'"')?;
1184        while let Some(byte) = self.peek() {
1185            match byte {
1186                b'"' => {
1187                    self.pos += 1;
1188                    let raw = &self.input[start..self.pos];
1189                    if raw.len() > MAX_JSON_STRING_BYTES {
1190                        return Err(BoundaryError::ResourceLimit(format!(
1191                            "json-string-bytes>{MAX_JSON_STRING_BYTES}"
1192                        )));
1193                    }
1194                    let parsed = serde_json::from_str::<String>(raw)
1195                        .map_err(|error| self.invalid(error.to_string()))?;
1196                    return Ok((parsed, start));
1197                }
1198                b'\\' => {
1199                    self.pos += 1;
1200                    let escaped = self
1201                        .peek()
1202                        .ok_or_else(|| self.invalid("unterminated string escape"))?;
1203                    if escaped == b'u' {
1204                        self.pos += 1;
1205                        for _ in 0..4 {
1206                            let Some(hex) = self.peek() else {
1207                                return Err(self.invalid("unterminated unicode escape"));
1208                            };
1209                            if !hex.is_ascii_hexdigit() {
1210                                return Err(self.invalid("invalid unicode escape"));
1211                            }
1212                            self.pos += 1;
1213                        }
1214                    } else {
1215                        if !matches!(
1216                            escaped,
1217                            b'"' | b'\\' | b'/' | b'b' | b'f' | b'n' | b'r' | b't'
1218                        ) {
1219                            return Err(self.invalid("invalid string escape"));
1220                        }
1221                        self.pos += 1;
1222                    }
1223                }
1224                0x00..=0x1f => return Err(self.invalid("control byte in string")),
1225                _ => self.pos += 1,
1226            }
1227        }
1228        Err(self.invalid("unterminated string"))
1229    }
1230
1231    fn parse_number(&mut self) -> Result<(), BoundaryError> {
1232        let start = self.pos;
1233        if self.consume(b'-') && self.peek().is_none() {
1234            return Err(self.invalid("invalid number"));
1235        }
1236        match self.peek() {
1237            Some(b'0') => self.pos += 1,
1238            Some(b'1'..=b'9') => {
1239                self.pos += 1;
1240                while self.peek().is_some_and(|byte| byte.is_ascii_digit()) {
1241                    self.pos += 1;
1242                }
1243            }
1244            _ => return Err(self.invalid("invalid number")),
1245        }
1246        if self.consume(b'.') {
1247            if !self.peek().is_some_and(|byte| byte.is_ascii_digit()) {
1248                return Err(self.invalid("invalid fractional number"));
1249            }
1250            while self.peek().is_some_and(|byte| byte.is_ascii_digit()) {
1251                self.pos += 1;
1252            }
1253        }
1254        if self.peek().is_some_and(|byte| matches!(byte, b'e' | b'E')) {
1255            self.pos += 1;
1256            if self.peek().is_some_and(|byte| matches!(byte, b'+' | b'-')) {
1257                self.pos += 1;
1258            }
1259            if !self.peek().is_some_and(|byte| byte.is_ascii_digit()) {
1260                return Err(self.invalid("invalid exponent"));
1261            }
1262            while self.peek().is_some_and(|byte| byte.is_ascii_digit()) {
1263                self.pos += 1;
1264            }
1265        }
1266
1267        let raw = &self.input[start..self.pos];
1268        let value = serde_json::from_str::<Value>(raw)
1269            .map_err(|error| self.invalid(format!("invalid number: {error}")))?;
1270        if !value.is_number() {
1271            return Err(self.invalid("invalid number"));
1272        }
1273        Ok(())
1274    }
1275
1276    fn parse_literal(&mut self, literal: &[u8]) -> Result<(), BoundaryError> {
1277        if self.bytes[self.pos..].starts_with(literal) {
1278            self.pos += literal.len();
1279            Ok(())
1280        } else {
1281            Err(self.invalid("invalid literal"))
1282        }
1283    }
1284
1285    fn skip_ws(&mut self) {
1286        while self
1287            .peek()
1288            .is_some_and(|byte| matches!(byte, b' ' | b'\n' | b'\r' | b'\t'))
1289        {
1290            self.pos += 1;
1291        }
1292    }
1293
1294    fn peek(&self) -> Option<u8> {
1295        self.bytes.get(self.pos).copied()
1296    }
1297
1298    fn expect(&mut self, expected: u8) -> Result<(), BoundaryError> {
1299        if self.consume(expected) {
1300            Ok(())
1301        } else {
1302            Err(self.invalid(format!("expected byte '{}'", expected as char)))
1303        }
1304    }
1305
1306    fn consume(&mut self, expected: u8) -> bool {
1307        if self.peek() == Some(expected) {
1308            self.pos += 1;
1309            true
1310        } else {
1311            false
1312        }
1313    }
1314
1315    fn invalid(&self, message: impl Into<String>) -> BoundaryError {
1316        BoundaryError::InvalidJson(format!("{} at byte {}", message.into(), self.pos))
1317    }
1318}
1319
1320fn object_child_path(parent: &str, key: &str) -> String {
1321    if key
1322        .chars()
1323        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
1324    {
1325        format!("{parent}.{key}")
1326    } else {
1327        format!(
1328            "{parent}[{}]",
1329            serde_json::to_string(key).unwrap_or_default()
1330        )
1331    }
1332}
1333
1334#[cfg(test)]
1335mod tests {
1336    use super::*;
1337
1338    #[test]
1339    fn strict_json_rejects_invalid_input() {
1340        assert!(parse_strict_json("{not json}").is_err());
1341    }
1342
1343    #[test]
1344    fn strict_json_accepts_valid_input() {
1345        assert!(parse_strict_json(r#"{"ok":true}"#).is_ok());
1346    }
1347
1348    #[test]
1349    fn canonicalize_json_sorts_object_keys() {
1350        let value = serde_json::json!({"b": 2, "a": 1});
1351
1352        assert_eq!(
1353            canonicalize_json(&value).expect("canonicalization should succeed"),
1354            r#"{"a":1,"b":2}"#
1355        );
1356    }
1357
1358    #[test]
1359    fn canonical_digest_is_64_hex_characters() {
1360        let value = serde_json::json!({"b": 2, "a": 1});
1361        let digest = canonical_digest(&value).expect("digest computation should succeed");
1362
1363        let hex = digest.hex();
1364        assert_eq!(hex.len(), 64);
1365        assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
1366    }
1367
1368    #[test]
1369    fn parse_strict_canonical_rejects_duplicate_keys() {
1370        let err = parse_strict_canonical(r#"{"a":1,"a":2}"#).unwrap_err();
1371        assert!(matches!(err, CanonicalJcsError::DuplicateKey { .. }));
1372    }
1373
1374    #[test]
1375    fn strict_json_rejects_duplicate_object_keys() {
1376        let outcome = compile_json_boundary(BoundaryCompileRequestV1::new(
1377            r#"{"treatment":"control","treatment":"variant"}"#,
1378        ));
1379
1380        assert!(!outcome.accepted);
1381        assert_eq!(outcome.duplicate_key_findings.len(), 1);
1382        assert_eq!(outcome.duplicate_key_findings[0].key, "treatment");
1383        assert!(outcome
1384            .reason_codes
1385            .contains(&"duplicate-json-object-key".into()));
1386    }
1387
1388    #[test]
1389    fn markdown_fence_repair_is_receipt_bearing() {
1390        let outcome = parse_json_boundary(
1391            "```json\n{\"ok\":true}\n```",
1392            BoundaryRepairPolicyV1::permissive_degraded_repair(),
1393        )
1394        .expect("fenced json should repair");
1395
1396        assert_eq!(outcome.value["ok"], true);
1397        assert!(outcome.repair_receipt.changed);
1398        assert_eq!(
1399            outcome.repair_receipt.repair_kind,
1400            "markdown-json-fence-stripped"
1401        );
1402        assert!(outcome.repair_receipt.before_digest.is_some());
1403        assert!(outcome.repair_receipt.after_digest.is_some());
1404        assert!(outcome
1405            .json_repair_receipt
1406            .as_ref()
1407            .is_some_and(|receipt| receipt.degraded));
1408    }
1409
1410    #[test]
1411    fn json_substring_extract_is_receipt_bearing() {
1412        let outcome = parse_json_boundary(
1413            "model said: {\"ok\":true,\"text\":\"brace } inside\"} done",
1414            BoundaryRepairPolicyV1::permissive_degraded_repair(),
1415        )
1416        .expect("embedded json should extract");
1417
1418        assert_eq!(outcome.value["ok"], true);
1419        assert!(outcome.repair_receipt.changed);
1420        assert_eq!(
1421            outcome.repair_receipt.repair_kind,
1422            "json-substring-extracted"
1423        );
1424        assert!(outcome
1425            .json_repair_receipt
1426            .as_ref()
1427            .is_some_and(|receipt| receipt.degraded));
1428    }
1429
1430    #[test]
1431    fn default_boundary_policy_rejects_repairable_wrappers() {
1432        let fenced = parse_json_boundary(
1433            "```json\n{\"ok\":true}\n```",
1434            BoundaryRepairPolicyV1::default(),
1435        );
1436        let embedded = compile_json_boundary(BoundaryCompileRequestV1::new(
1437            "model said: {\"ok\":true} done",
1438        ));
1439
1440        assert!(fenced.is_err());
1441        assert!(!embedded.accepted);
1442        assert!(embedded.repair_receipt.is_none());
1443        assert!(embedded
1444            .reason_codes
1445            .iter()
1446            .any(|reason| reason.starts_with("boundary-parse-failed")));
1447    }
1448
1449    #[test]
1450    fn unsupported_schema_keyword_fails_closed() {
1451        let schema = serde_json::json!({
1452            "type": "string",
1453            "pattern": "^a"
1454        });
1455        let receipt = validate_json_schema(&schema, &serde_json::json!("abc"));
1456
1457        assert!(!receipt.valid);
1458        assert!(receipt
1459            .errors
1460            .iter()
1461            .any(|error| error.contains("unsupported schema keyword 'pattern'")));
1462        assert!(receipt
1463            .reason_codes
1464            .contains(&"schema-validation-failed".into()));
1465    }
1466
1467    #[test]
1468    fn supported_schema_array_and_additional_property_object_are_enforced() {
1469        let schema = serde_json::json!({
1470            "type": "object",
1471            "properties": {
1472                "tags": {
1473                    "type": "array",
1474                    "maxItems": 2,
1475                    "items": { "type": "string", "maxLength": 3 }
1476                }
1477            },
1478            "additionalProperties": { "type": "integer", "minimum": 1 }
1479        });
1480        let invalid = validate_json_schema(
1481            &schema,
1482            &serde_json::json!({
1483                "tags": ["ok", "toolong", "extra"],
1484                "count": 0
1485            }),
1486        );
1487
1488        assert!(!invalid.valid);
1489        assert!(invalid
1490            .errors
1491            .iter()
1492            .any(|error| error.contains("array length is above maxItems 2")));
1493        assert!(invalid
1494            .errors
1495            .iter()
1496            .any(|error| error.contains("string length is above maxLength 3")));
1497        assert!(invalid
1498            .errors
1499            .iter()
1500            .any(|error| error.contains("value is below minimum 1")));
1501    }
1502
1503    #[test]
1504    fn local_schema_ref_and_format_are_semantic_not_silent() {
1505        let schema = serde_json::json!({
1506            "$schema": "http://json-schema.org/draft-07/schema#",
1507            "definitions": {
1508                "stamp": { "type": "string", "format": "date-time" }
1509            },
1510            "type": "object",
1511            "required": ["created_at"],
1512            "properties": {
1513                "created_at": { "$ref": "#/definitions/stamp" }
1514            },
1515            "additionalProperties": false
1516        });
1517        let invalid =
1518            validate_json_schema(&schema, &serde_json::json!({"created_at": "not-a-date"}));
1519        let valid = validate_json_schema(
1520            &schema,
1521            &serde_json::json!({"created_at": "2026-05-07T00:00:00Z"}),
1522        );
1523
1524        assert!(!invalid.valid);
1525        assert!(invalid
1526            .errors
1527            .iter()
1528            .any(|error| error.contains("not valid date-time")));
1529        assert!(valid.valid, "{:?}", valid.errors);
1530    }
1531
1532    #[test]
1533    fn schema_invalid_input_is_blocked_by_compile_outcome() {
1534        let schema = serde_json::json!({
1535            "type": "object",
1536            "additionalProperties": false,
1537            "required": ["path"],
1538            "properties": {
1539                "path": { "type": "string" }
1540            }
1541        });
1542        let outcome = compile_json_boundary(
1543            BoundaryCompileRequestV1::new(r#"{"path":7}"#).with_schema(schema),
1544        );
1545
1546        assert!(!outcome.accepted);
1547        let receipt = outcome
1548            .schema_validation
1549            .expect("schema validation receipt");
1550        assert_eq!(
1551            receipt.kind,
1552            aidens_contracts::ArtifactKindV1::SchemaValidation
1553        );
1554        assert!(!receipt.valid);
1555        assert!(receipt
1556            .reason_codes
1557            .contains(&"schema-validation-failed".into()));
1558    }
1559
1560    #[test]
1561    fn memory_claim_input_validation_blocks_missing_bitemporal_fields() {
1562        let receipt = validate_memory_claim_input(&serde_json::json!({
1563            "subject": "repo",
1564            "predicate": "status",
1565            "object": "partial"
1566        }));
1567
1568        assert!(!receipt.valid);
1569        assert_eq!(receipt.tool_id.as_deref(), Some("aidens:memory-write:1"));
1570        assert!(receipt
1571            .errors
1572            .iter()
1573            .any(|error| error.contains("valid_from")));
1574    }
1575
1576    #[test]
1577    fn display_digest_is_stable_across_key_order_and_whitespace() {
1578        let left: Value = serde_json::from_str(r#"{"b":2,"a":{"z":false,"n":1}}"#).unwrap();
1579        let right: Value = serde_json::from_str(
1580            r#"{
1581              "a": { "n": 1, "z": false },
1582              "b": 2
1583            }"#,
1584        )
1585        .unwrap();
1586
1587        assert_eq!(
1588            non_authoritative_boundary_json_display_digest(&left),
1589            non_authoritative_boundary_json_display_digest(&right)
1590        );
1591        assert!(non_authoritative_boundary_json_display_digest(&left).starts_with("blake3:"));
1592        assert_eq!(
1593            DisplayDigestV1::for_json_value(&left),
1594            DisplayDigestV1::for_json_value(&right)
1595        );
1596    }
1597
1598    #[test]
1599    fn repair_warns_when_treatment_critical_fields_are_unverifiable() {
1600        let mut request =
1601            BoundaryCompileRequestV1::new("prefix {\"treatment\":\"variant\"} suffix")
1602                .with_treatment_critical_fields(vec!["treatment".into()]);
1603        request.allow_json_substring_extract = true;
1604        let outcome = compile_json_boundary(request);
1605
1606        assert!(outcome.accepted);
1607        let repair = outcome.repair_receipt.expect("repair receipt");
1608        assert!(!repair.treatment_integrity_warnings.is_empty());
1609        assert!(repair
1610            .reason_codes
1611            .contains(&"treatment-integrity-unverifiable".into()));
1612    }
1613
1614    #[test]
1615    fn treatment_critical_json_pointer_paths_are_honored() {
1616        let mut request = BoundaryCompileRequestV1::new(
1617            "prefix {\"doses\":[{\"mg\":10}],\"a/b\":{\"~key\":\"x\"}} suffix",
1618        )
1619        .with_treatment_critical_fields(vec!["/doses/0/mg".into(), "/a~1b/~0key".into()])
1620        .with_hard_fail_on_treatment_change(true);
1621        request.allow_json_substring_extract = true;
1622        let outcome = compile_json_boundary(request);
1623
1624        assert!(!outcome.accepted);
1625        let repair = outcome.repair_receipt.expect("repair receipt");
1626        assert!(repair.hard_failed);
1627        assert!(repair
1628            .treatment_integrity_warnings
1629            .iter()
1630            .any(|warning| warning.ends_with(":/doses/0/mg")));
1631        assert!(repair
1632            .treatment_integrity_warnings
1633            .iter()
1634            .any(|warning| warning.ends_with(":/a~1b/~0key")));
1635    }
1636
1637    #[test]
1638    fn duplicate_key_scanner_handles_unicode_and_nested_objects() {
1639        let findings = detect_duplicate_keys(
1640            r#"{"\u0061":1,"a":2,"nested":{"x":1,"x":2},"arr":[{"k":1,"k":2}]}"#,
1641        )
1642        .unwrap();
1643
1644        assert_eq!(findings.len(), 3);
1645        assert!(findings
1646            .iter()
1647            .any(|finding| finding.path == "$" && finding.key == "a"));
1648        assert!(findings
1649            .iter()
1650            .any(|finding| finding.path == "$.nested" && finding.key == "x"));
1651        assert!(findings
1652            .iter()
1653            .any(|finding| finding.path == "$.arr[0]" && finding.key == "k"));
1654    }
1655
1656    #[test]
1657    fn resource_ceiling_rejects_excessive_depth_before_acceptance() {
1658        let mut input = String::new();
1659        for _ in 0..=MAX_JSON_DEPTH {
1660            input.push('[');
1661        }
1662        input.push_str("true");
1663        for _ in 0..=MAX_JSON_DEPTH {
1664            input.push(']');
1665        }
1666        let outcome = compile_json_boundary(BoundaryCompileRequestV1::new(input));
1667
1668        assert!(!outcome.accepted);
1669        assert!(outcome
1670            .reason_codes
1671            .contains(&"boundary-resource-limit-exceeded".into()));
1672        assert!(outcome
1673            .reason_codes
1674            .iter()
1675            .any(|reason| reason == &format!("json-depth>{MAX_JSON_DEPTH}")));
1676    }
1677
1678    #[test]
1679    fn treatment_integrity_warning_can_hard_fail_repair() {
1680        let mut request =
1681            BoundaryCompileRequestV1::new("prefix {\"treatment\":\"variant\"} suffix")
1682                .with_treatment_critical_fields(vec!["treatment".into()])
1683                .with_hard_fail_on_treatment_change(true);
1684        request.allow_json_substring_extract = true;
1685        let outcome = compile_json_boundary(request);
1686
1687        assert!(!outcome.accepted);
1688        assert!(outcome
1689            .repair_receipt
1690            .as_ref()
1691            .is_some_and(|receipt| receipt.hard_failed));
1692        assert!(outcome
1693            .reason_codes
1694            .contains(&"treatment-integrity-hard-fail".into()));
1695    }
1696
1697    #[test]
1698    fn p28_boundary_compile_receipt_rejects_duplicate_keys() {
1699        let profile = aidens_contracts::BoundaryCompilerProfileV1::strict_json("schema:test");
1700        let outcome =
1701            compile_json_boundary(BoundaryCompileRequestV1::new(r#"{"path":"a","path":"b"}"#));
1702        assert!(!outcome.accepted);
1703        assert_eq!(outcome.duplicate_key_findings.len(), 1);
1704        let receipt = aidens_contracts::BoundaryCompileReceiptV1::rejected_duplicate_key(
1705            &profile,
1706            &serde_json::json!({"raw": "{\"path\":\"a\",\"path\":\"b\"}"}),
1707        );
1708
1709        assert!(!receipt.accepted);
1710        assert!(receipt.duplicate_keys_rejected);
1711        assert!(receipt
1712            .reason_codes
1713            .contains(&"duplicate-json-object-key".into()));
1714    }
1715
1716    #[test]
1717    fn p28_treatment_integrity_receipt_blocks_silent_treatment_change() {
1718        let profile = aidens_contracts::BoundaryCompilerProfileV1::strict_json("schema:treatment");
1719        let before = serde_json::json!({"treatment":"control","value":1});
1720        let after = serde_json::json!({"treatment":"variant","value":1});
1721        let treatment = aidens_contracts::TreatmentIntegrityReceiptV1::compare(
1722            &profile,
1723            &before,
1724            &after,
1725            vec!["/treatment".into()],
1726        );
1727        let repair = aidens_contracts::BoundaryRepairReceiptV1::new(
1728            &profile,
1729            "json-substring-extracted",
1730            &before,
1731            &after,
1732            &treatment,
1733        );
1734
1735        assert!(treatment.treatment_changed);
1736        assert!(treatment.hard_failed);
1737        assert!(!repair.accepted);
1738        assert_eq!(repair.treatment_integrity_receipt_id, treatment.receipt_id);
1739    }
1740
1741    #[test]
1742    fn boundary_repair_matches_reference_interpreter() {
1743        for case in aidens_testkit::reference_cases()
1744            .into_iter()
1745            .filter(|case| case.domain == aidens_contracts::ReferenceDomainV1::BoundaryRepair)
1746        {
1747            let mut request = BoundaryCompileRequestV1::new(
1748                case.input["raw"].as_str().expect("raw reference input"),
1749            );
1750            request.allow_markdown_fence_repair = case.input["allow_markdown_fence_repair"]
1751                .as_bool()
1752                .unwrap_or(true);
1753            request.allow_json_substring_extract = case.input["allow_json_substring_extract"]
1754                .as_bool()
1755                .unwrap_or(true);
1756            let outcome = compile_json_boundary(request);
1757            let actual = serde_json::json!({
1758                "accepted": outcome.accepted,
1759                "degraded": outcome.degraded,
1760                "repair_kind": outcome.repair_receipt.as_ref().map(|receipt| receipt.repair_kind.clone()),
1761                "reason_codes": outcome.reason_codes
1762            });
1763            let report = aidens_testkit::compare_case_to_actual(
1764                &case,
1765                "aidens-boundary-kit::compile_json_boundary",
1766                actual,
1767            );
1768
1769            assert!(
1770                report.passed,
1771                "{}",
1772                report
1773                    .findings
1774                    .iter()
1775                    .map(|finding| finding.human_diff.as_str())
1776                    .collect::<Vec<_>>()
1777                    .join("\n")
1778            );
1779        }
1780    }
1781}