391 lines
18 KiB
Python
391 lines
18 KiB
Python
"""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",
|
|
)
|