173 lines
6.7 KiB
Python
173 lines
6.7 KiB
Python
"""Canonical CASAN wire-contract builders and lightweight validation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Mapping
|
|
|
|
|
|
CONTRACT_VERSION = "1.0.0"
|
|
|
|
|
|
def utc_now() -> str:
|
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def actor(actor_id: str, actor_type: str, issuer: str, trust_level: str, auth_evidence_ref: str | None = None) -> dict[str, Any]:
|
|
return {
|
|
"actor_id": actor_id,
|
|
"actor_type": actor_type,
|
|
"issuer": issuer,
|
|
"trust_level": trust_level,
|
|
"authentication_evidence_ref": auth_evidence_ref,
|
|
}
|
|
|
|
|
|
def outcome(execution: str, assurance: str, certification: str, business: str = "not_evaluated") -> dict[str, str]:
|
|
return {
|
|
"execution_result": execution,
|
|
"assurance_result": assurance,
|
|
"certification_result": certification,
|
|
"business_result": business,
|
|
}
|
|
|
|
|
|
def evidence_digest(evidence: Mapping[str, Any]) -> str:
|
|
"""Digest the claim-bearing evidence metadata, excluding its digest field."""
|
|
|
|
material = {
|
|
key: evidence.get(key)
|
|
for key in (
|
|
"evidence_id", "claim_refs", "producer_identity", "timestamp",
|
|
"artifact_ref", "validation_status", "retention_class", "category",
|
|
)
|
|
}
|
|
payload = json.dumps(material, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
|
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def verify_evidence_integrity(evidence: Mapping[str, Any]) -> bool:
|
|
integrity = evidence.get("integrity")
|
|
if not isinstance(integrity, Mapping) or integrity.get("algorithm") != "sha256":
|
|
return False
|
|
digest = integrity.get("digest")
|
|
return isinstance(digest, str) and digest == evidence_digest(evidence)
|
|
|
|
|
|
def validate_bundle(bundle: Mapping[str, Any]) -> list[str]:
|
|
"""Return deterministic contract errors; an empty list means valid.
|
|
|
|
JSON Schema is canonical. This small validator keeps runtime adapters stdlib-
|
|
only and catches the invariants needed at enforcement boundaries.
|
|
"""
|
|
|
|
errors: list[str] = []
|
|
if bundle.get("schema_version") != CONTRACT_VERSION:
|
|
errors.append("unsupported_schema_version")
|
|
run = bundle.get("run")
|
|
if not isinstance(run, Mapping):
|
|
errors.append("missing_execution_run")
|
|
return errors
|
|
for key in ("run_id", "runtime", "mode", "status", "correlation_id", "outcome"):
|
|
if not run.get(key):
|
|
errors.append(f"run.{key}_required")
|
|
actors = bundle.get("actors")
|
|
actor_ids: set[object] = set()
|
|
if not isinstance(actors, list) or not actors:
|
|
errors.append("actors_required")
|
|
else:
|
|
actor_ids = {item.get("actor_id") for item in actors if isinstance(item, Mapping)}
|
|
if "" in actor_ids or None in actor_ids:
|
|
errors.append("actor_identity_required")
|
|
steps = bundle.get("steps")
|
|
if not isinstance(steps, list):
|
|
errors.append("steps_required")
|
|
else:
|
|
for index, step in enumerate(steps):
|
|
if not isinstance(step, Mapping):
|
|
errors.append(f"steps.{index}_invalid")
|
|
continue
|
|
for key in ("step_id", "run_id", "sequence", "actor_ref", "action", "outcome"):
|
|
if step.get(key) is None:
|
|
errors.append(f"steps.{index}.{key}_required")
|
|
if step.get("actor_ref") not in actor_ids:
|
|
errors.append(f"steps.{index}.actor_ref_unknown")
|
|
|
|
evidence = bundle.get("evidence")
|
|
evidence_ids: set[object] = set()
|
|
if not isinstance(evidence, list):
|
|
errors.append("evidence_required")
|
|
else:
|
|
for index, item in enumerate(evidence):
|
|
if not isinstance(item, Mapping):
|
|
errors.append(f"evidence.{index}_invalid")
|
|
continue
|
|
evidence_ids.add(item.get("evidence_id"))
|
|
if not verify_evidence_integrity(item):
|
|
errors.append(f"evidence.{index}.integrity_invalid")
|
|
|
|
claims = bundle.get("claims")
|
|
claim_ids = {
|
|
item.get("claim_id") for item in claims or [] if isinstance(item, Mapping)
|
|
} if isinstance(claims, list) else set()
|
|
if isinstance(claims, list):
|
|
for index, claim in enumerate(claims):
|
|
if not isinstance(claim, Mapping):
|
|
errors.append(f"claims.{index}_invalid")
|
|
continue
|
|
for evidence_ref in claim.get("evidence_refs") or []:
|
|
if evidence_ref not in evidence_ids:
|
|
errors.append(f"claims.{index}.evidence_ref_unknown")
|
|
if isinstance(evidence, list):
|
|
for index, item in enumerate(evidence):
|
|
if isinstance(item, Mapping):
|
|
for claim_ref in item.get("claim_refs") or []:
|
|
if claim_ref not in claim_ids:
|
|
errors.append(f"evidence.{index}.claim_ref_unknown")
|
|
|
|
delegations = bundle.get("delegations")
|
|
if delegations is not None:
|
|
errors.extend(_validate_delegations(delegations, actor_ids))
|
|
if run.get("mode") == "observe" and isinstance(run.get("outcome"), Mapping):
|
|
if run["outcome"].get("certification_result") == "certified":
|
|
errors.append("observed_only_cannot_be_certified")
|
|
return errors
|
|
|
|
|
|
def _validate_delegations(delegations: object, actor_ids: set[object]) -> list[str]:
|
|
if not isinstance(delegations, list):
|
|
return ["delegations_invalid"]
|
|
errors: list[str] = []
|
|
records = {
|
|
item.get("delegation_id"): item
|
|
for item in delegations if isinstance(item, Mapping) and item.get("delegation_id")
|
|
}
|
|
for index, item in enumerate(delegations):
|
|
if not isinstance(item, Mapping):
|
|
errors.append(f"delegations.{index}_invalid")
|
|
continue
|
|
for actor_key in ("delegator_ref", "delegate_ref"):
|
|
if item.get(actor_key) not in actor_ids:
|
|
errors.append(f"delegations.{index}.{actor_key}_unknown")
|
|
current: Mapping[str, Any] = item
|
|
visited: set[object] = set()
|
|
depth = 0
|
|
while current.get("parent_delegation_ref"):
|
|
parent_ref = current.get("parent_delegation_ref")
|
|
if parent_ref in visited:
|
|
errors.append(f"delegations.{index}.cycle")
|
|
break
|
|
visited.add(parent_ref)
|
|
parent = records.get(parent_ref)
|
|
if not isinstance(parent, Mapping):
|
|
errors.append(f"delegations.{index}.parent_unknown")
|
|
break
|
|
depth += 1
|
|
current = parent
|
|
maximum_depth = item.get("maximum_depth")
|
|
if isinstance(maximum_depth, int) and depth > maximum_depth:
|
|
errors.append(f"delegations.{index}.maximum_depth_exceeded")
|
|
return errors
|