1mod 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
68pub fn canonical_digest(
70 value: &serde_json::Value,
71) -> Result<boundary_compiler::ContentDigest, boundary_compiler::JcsError> {
72 boundary_compiler::ContentDigest::compute(value)
73}
74
75pub fn canonicalize_json(value: &serde_json::Value) -> Result<String, boundary_compiler::JcsError> {
77 boundary_compiler::Canonicalizer::new().canonicalize(value)
78}
79
80pub 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}