"""Semantic checks that portable JSON Schema cannot express.""" from collections.abc import Iterable, Mapping from datetime import datetime from . import taxonomy SUPPORTED_ARCHIVE_SCHEMA_VERSION = 1 SUPPORTED_HANDOFF_SCHEMA_VERSION = 1 class ContractValidationError(ValueError): """Raised when a document violates a version or referential contract.""" def ensure_supported_version(document: Mapping[str, object], expected: int) -> None: version = document.get("schema_version") if type(version) is not int or version != expected: raise ContractValidationError( f"Unsupported schema_version {version!r}; expected {expected}" ) def _objects(value: object, field: str) -> Iterable[Mapping[str, object]]: if not isinstance(value, list): raise ContractValidationError(f"{field} must be an array") for index, item in enumerate(value): if not isinstance(item, Mapping): raise ContractValidationError(f"{field}[{index}] must be an object") yield item def _ids(items: Iterable[Mapping[str, object]], field: str) -> set[str]: result: set[str] = set() for item in items: value = item.get(field) if not isinstance(value, str): raise ContractValidationError(f"{field} must be a string") if value in result: raise ContractValidationError(f"Duplicate {field}: {value}") result.add(value) return result def _references(item: Mapping[str, object], field: str) -> set[str]: raw = item.get(field, []) if not isinstance(raw, list) or any(not isinstance(value, str) for value in raw): raise ContractValidationError(f"{field} must contain string IDs") return set(raw) def _require_subset(values: set[str], allowed: set[str], context: str) -> None: dangling = values - allowed if dangling: raise ContractValidationError(f"{context} contains dangling references: {sorted(dangling)}") def validate_classification(classification: Mapping[str, object]) -> None: family_raw = classification.get("family") if not isinstance(family_raw, str): raise ContractValidationError("Unknown classification family") try: family = taxonomy.Family(family_raw) except ValueError as error: raise ContractValidationError("Unknown classification family") from error view = classification.get("view") if not isinstance(view, str) or view not in taxonomy.SCREEN_VIEWS[family]: raise ContractValidationError(f"Unknown {family.value} view: {view!r}") confidence = classification.get("confidence") if ( isinstance(confidence, bool) or not isinstance(confidence, int | float) or not 0 <= confidence <= 1 ): raise ContractValidationError("Classification confidence must be between 0 and 1") gear_slot = classification.get("gear_slot") if family is taxonomy.Family.GEAR: if gear_slot not in taxonomy.GEAR_SLOTS: raise ContractValidationError(f"Unknown Gear slot: {gear_slot!r}") elif gear_slot is not None: raise ContractValidationError("Only Gear classifications may set gear_slot") record_types = classification.get("record_types") if not isinstance(record_types, list) or any( not isinstance(record_type, str) for record_type in record_types ): raise ContractValidationError("record_types must contain strings") if len(record_types) != len(set(record_types)): raise ContractValidationError("record_types must be unique") if len(record_types) > 8: raise ContractValidationError("record_types cannot contain more than 8 values") try: normalized_record_types = {taxonomy.RecordType(record_type) for record_type in record_types} except ValueError as error: raise ContractValidationError("Unknown canonical record type") from error allowed = set(taxonomy.RECORD_TYPES_BY_VIEW[(family, view)]) if family is taxonomy.Family.UNKNOWN: if record_types: raise ContractValidationError("Unknown screens cannot claim canonical record types") reason = classification.get("abstention_reason") if reason not in taxonomy.ABSTENTION_REASONS: raise ContractValidationError("Unknown screens require an abstention reason") else: if not record_types or not normalized_record_types.issubset(allowed): raise ContractValidationError( f"record_types are incompatible with {family.value}/{view}" ) if classification.get("abstention_reason") is not None: raise ContractValidationError("Known screens cannot set abstention_reason") def _validate_evidence(value: Mapping[str, object]) -> None: _validate_value_state(value, "evidence") confidence = value.get("confidence") if ( isinstance(confidence, bool) or not isinstance(confidence, int | float) or not 0 <= confidence <= 1 ): raise ContractValidationError("Evidence confidence must be between 0 and 1") region = value.get("region") if not isinstance(region, Mapping): raise ContractValidationError("Evidence region must be an object") coordinate_space = region.get("coordinate_space") numeric_coordinates: list[int | float] = [] for name in ("x", "y", "width", "height"): number = region.get(name) if isinstance(number, bool) or not isinstance(number, int | float): raise ContractValidationError("Evidence bounds must be numeric") numeric_coordinates.append(number) x, y, width, height = numeric_coordinates if coordinate_space == "normalized": if x < 0 or y < 0 or width <= 0 or height <= 0 or x + width > 1 or y + height > 1: raise ContractValidationError("Normalized evidence bounds must fit within the image") elif coordinate_space == "pixels": if any(not isinstance(number, int) for number in numeric_coordinates): raise ContractValidationError("Pixel evidence bounds must be integers") if x < 0 or y < 0 or width <= 0 or height <= 0: raise ContractValidationError("Pixel evidence bounds must be positive and non-negative") else: raise ContractValidationError(f"Unknown coordinate space: {coordinate_space!r}") def _validate_value_state(value: Mapping[str, object], context: str) -> None: raw_text = value.get("raw_text") normalized = value.get("normalized_value") missing_reason = value.get("missing_reason") if raw_text is not None and not isinstance(raw_text, str): raise ContractValidationError(f"{context} raw_text must be a string or null") if normalized is None and not isinstance(missing_reason, str): raise ContractValidationError(f"Null {context} requires missing_reason") if normalized is not None and missing_reason is not None: raise ContractValidationError(f"Present {context} cannot set missing_reason") def _value_state(value: Mapping[str, object], context: str) -> tuple[object, object, object]: _validate_value_state(value, context) return value.get("raw_text"), value.get("normalized_value"), value.get("missing_reason") def _parse_correction_time(value: object) -> datetime: if not isinstance(value, str): raise ContractValidationError("Correction corrected_at must be a timestamp") try: parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) except ValueError as error: raise ContractValidationError("Correction corrected_at must be a timestamp") from error if parsed.tzinfo is None: raise ContractValidationError("Correction corrected_at must include a UTC offset") return parsed def _validate_correction_history( evidence: Mapping[str, object], seen_correction_ids: set[str] ) -> None: history = evidence.get("correction_history") if not isinstance(history, list): raise ContractValidationError("correction_history must be an array") previous_after: tuple[object, object, object] | None = None previous_time: datetime | None = None for index, correction in enumerate(history): if not isinstance(correction, Mapping): raise ContractValidationError(f"correction_history[{index}] must be an object") correction_id = correction.get("correction_id") if not isinstance(correction_id, str): raise ContractValidationError("correction_id must be a string") if correction_id in seen_correction_ids: raise ContractValidationError(f"Duplicate correction_id: {correction_id}") seen_correction_ids.add(correction_id) corrected_at = _parse_correction_time(correction.get("corrected_at")) if previous_time is not None and corrected_at <= previous_time: raise ContractValidationError("Correction history timestamps must be strictly ordered") before = correction.get("before") after = correction.get("after") if not isinstance(before, Mapping) or not isinstance(after, Mapping): raise ContractValidationError("Correction before and after must be value states") before_state = _value_state(before, "correction before") after_state = _value_state(after, "correction after") if previous_after is not None and before_state != previous_after: raise ContractValidationError("Correction history contains a disconnected value state") previous_after = after_state previous_time = corrected_at if previous_after is not None and previous_after != _value_state(evidence, "evidence"): raise ContractValidationError("Correction history final state does not match evidence") def validate_archive_document(document: Mapping[str, object]) -> None: validate_archive_references(document) for screen in _objects(document.get("screens"), "screens"): classification = screen.get("classification") if not isinstance(classification, Mapping): raise ContractValidationError("screen.classification must be an object") validate_classification(classification) seen_correction_ids: set[str] = set() for value in _objects(document.get("evidence_values"), "evidence_values"): _validate_evidence(value) _validate_correction_history(value, seen_correction_ids) def validate_handoff_document(document: Mapping[str, object]) -> None: validate_handoff_references(document) expected_boundary: dict[str, object] = { "intended_destination": "authenticated_isac_intake", "mutation_authority": "none", "requires_authenticated_action": True, "preserves_moderation": True, "creates_audit_events": False, } if document.get("trust_boundary") != expected_boundary: raise ContractValidationError("The handoff trust boundary is invalid") seen_correction_ids: set[str] = set() for value in _objects(document.get("evidence_values"), "evidence_values"): _validate_evidence(value) _validate_correction_history(value, seen_correction_ids) def validate_archive_references(document: Mapping[str, object]) -> None: ensure_supported_version(document, SUPPORTED_ARCHIVE_SCHEMA_VERSION) artifacts = list(_objects(document.get("artifacts"), "artifacts")) captures = list(_objects(document.get("captures"), "captures")) screens = list(_objects(document.get("screens"), "screens")) jobs = list(_objects(document.get("jobs"), "jobs")) extractions = list(_objects(document.get("extractions"), "extractions")) groups = list(_objects(document.get("groups"), "groups")) reviews = list(_objects(document.get("reviews"), "reviews")) exports = list(_objects(document.get("exports"), "exports")) errors = list(_objects(document.get("errors"), "errors")) evidence = list(_objects(document.get("evidence_values"), "evidence_values")) instances = list(_objects(document.get("observed_instances"), "observed_instances")) candidates = list(_objects(document.get("canonical_candidates"), "canonical_candidates")) artifact_ids = _ids(artifacts, "artifact_id") _ids(captures, "capture_id") screen_ids = _ids(screens, "screen_id") _ids(jobs, "job_id") _ids(extractions, "extraction_id") group_ids = _ids(groups, "group_id") _ids(reviews, "review_id") _ids(exports, "export_id") error_ids = _ids(errors, "error_id") evidence_ids = _ids(evidence, "evidence_id") instance_ids = _ids(instances, "instance_id") candidate_ids = _ids(candidates, "candidate_id") artifact_hashes = { str(artifact.get("artifact_id")): str(artifact.get("sha256")) for artifact in artifacts } for capture in captures: _require_subset({str(capture.get("artifact_id"))}, artifact_ids, "capture.artifact_id") for artifact in artifacts: parent_id = artifact.get("parent_artifact_id") if parent_id is not None: if str(parent_id) == str(artifact.get("artifact_id")): raise ContractValidationError("artifact.parent_artifact_id cannot reference itself") _require_subset({str(parent_id)}, artifact_ids, "artifact.parent_artifact_id") for screen in screens: _require_subset({str(screen.get("artifact_id"))}, artifact_ids, "screen.artifact_id") for value in evidence: artifact_id = str(value.get("image_artifact_id")) _require_subset( {artifact_id}, artifact_ids, "evidence.image_artifact_id", ) if artifact_hashes.get(artifact_id) != value.get("image_sha256"): raise ContractValidationError("Evidence image_sha256 does not match its artifact") for job in jobs: last_error_id = job.get("last_error_id") if last_error_id is not None: _require_subset({str(last_error_id)}, error_ids, "job.last_error_id") for extraction in extractions: _require_subset({str(extraction.get("screen_id"))}, screen_ids, "extraction.screen_id") _require_subset( _references(extraction, "evidence_refs"), evidence_ids, "extraction.evidence_refs", ) for group in groups: _require_subset(_references(group, "screen_ids"), screen_ids, "group.screen_ids") supersedes_group_ids = _references(group, "supersedes_group_ids") if str(group.get("group_id")) in supersedes_group_ids: raise ContractValidationError("group.supersedes_group_ids cannot reference itself") _require_subset( supersedes_group_ids, group_ids, "group.supersedes_group_ids", ) for instance in instances: _require_subset( _references(instance, "source_screen_ids"), screen_ids, "observed_instance.source_screen_ids", ) for field in _objects(instance.get("fields"), "observed_instance.fields"): _require_subset( _references(field, "evidence_refs"), evidence_ids, "observed_instance.fields.evidence_refs", ) context = instance.get("observed_context") if not isinstance(context, Mapping): raise ContractValidationError("observed_instance.observed_context must be an object") for context_name in ("level", "quality", "tier", "upgrade_state"): context_field = context.get(context_name) if not isinstance(context_field, Mapping): raise ContractValidationError(f"observed_context.{context_name} must be an object") _require_subset( _references(context_field, "evidence_refs"), evidence_ids, f"observed_context.{context_name}.evidence_refs", ) for candidate in candidates: _require_subset( _references(candidate, "source_instance_ids"), instance_ids, "canonical_candidate.source_instance_ids", ) _require_subset( _references(candidate, "evidence_refs"), evidence_ids, "canonical_candidate.evidence_refs", ) for field in _objects(candidate.get("fields"), "canonical_candidate.fields"): _require_subset( _references(field, "evidence_refs"), evidence_ids, "canonical_candidate.fields.evidence_refs", ) for review in reviews: _require_subset({str(review.get("candidate_id"))}, candidate_ids, "review.candidate_id") for export in exports: export_artifact_id = export.get("artifact_id") if export_artifact_id is not None: _require_subset({str(export_artifact_id)}, artifact_ids, "export.artifact_id") def validate_handoff_references(document: Mapping[str, object]) -> None: ensure_supported_version(document, SUPPORTED_HANDOFF_SCHEMA_VERSION) evidence = list(_objects(document.get("evidence_values"), "evidence_values")) records = list(_objects(document.get("records"), "records")) evidence_ids = _ids(evidence, "evidence_id") artifacts = list(_objects(document.get("source_artifacts"), "source_artifacts")) artifact_ids = _ids(artifacts, "artifact_id") artifact_hashes = { str(artifact.get("artifact_id")): str(artifact.get("sha256")) for artifact in artifacts } _ids(records, "handoff_record_id") for value in evidence: artifact_id = str(value.get("image_artifact_id")) _require_subset( {artifact_id}, artifact_ids, "evidence.image_artifact_id", ) if artifact_hashes.get(artifact_id) != value.get("image_sha256"): raise ContractValidationError("Evidence image_sha256 does not match its artifact") for record in records: for field in _objects(record.get("fields"), "record.fields"): _require_subset( _references(field, "evidence_refs"), evidence_ids, "record.fields.evidence_refs", ) for section_name in ("relationships", "mechanics"): section = record.get(section_name) if not isinstance(section, Mapping): raise ContractValidationError(f"record.{section_name} must be an object") _require_subset( _references(section, "evidence_refs"), evidence_ids, f"record.{section_name}.evidence_refs", )