diff --git a/.kiro/specs/detection-core/tasks.md b/.kiro/specs/detection-core/tasks.md index 563540d..c63ecc5 100644 --- a/.kiro/specs/detection-core/tasks.md +++ b/.kiro/specs/detection-core/tasks.md @@ -64,7 +64,7 @@ - Refuse protected-population influence, redirected artifact identities, wrong purposes, and incomplete receipt history. - Done when matching subjects cannot mask a wrong influenced artifact and contaminated populations are deterministically refused. - _Requirements: 13.1, 13.3, 13.4_ -- [ ] 2.5 Bind immutable evidence packets to neutral decisions +- [x] 2.5 Bind immutable evidence packets to neutral decisions - Preserve family identity, security object, analyzed scope, ordered semantics, observations, family fields, completeness, and provenance. - Keep family decision codes separate from violation, satisfied, and indeterminate outcomes. - Require bounded scope for satisfaction, named reasons for indeterminate outcomes, and a valid deterministic, model-corroborated, or execution-backed basis. diff --git a/src/openultrasast/core/evidence.py b/src/openultrasast/core/evidence.py new file mode 100644 index 0000000..9a8a159 --- /dev/null +++ b/src/openultrasast/core/evidence.py @@ -0,0 +1,414 @@ +"""Immutable family-neutral evidence packets and neutral decisions.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from enum import Enum + +from openultrasast.core import canonical +from openultrasast.core.provenance import DecisionProvenance, ModelCallIdentity, ProvenanceStamp +from openultrasast.core.semantics import ( + EvidenceProvenance, + FieldValue, + Observation, + RelationStatus, + SemanticNode, + SemanticRelation, + SourceLocation, +) + +EVIDENCE_PACKET_SCHEMA = "openultrasast.detection-core.evidence-packet.v2" +EVIDENCE_RECORD_SCHEMA = "openultrasast.detection-core.evidence-record.v2" +_DIGEST = re.compile(r"[0-9a-f]{64}") + + +class EvidenceValidationError(ValueError): + """An evidence packet or decision violates the neutral core contract.""" + + +class NeutralOutcome(Enum): + VIOLATION = "violation" + SATISFIED = "satisfied" + INDETERMINATE = "indeterminate" + + +class EvidenceBasis(Enum): + DETERMINISTIC = "deterministic" + MODEL_CORROBORATED = "model_corroborated" + EXECUTION_BACKED = "execution_backed" + + +def _require_text(value: object, field: str) -> None: + if not isinstance(value, str) or not value: + raise EvidenceValidationError(f"{field} must be nonempty text") + + +def _require_digest(value: object, field: str) -> None: + if not isinstance(value, str) or _DIGEST.fullmatch(value) is None: + raise EvidenceValidationError(f"{field} must be a full lowercase SHA-256 digest") + + +def _require_text_tuple(value: object, field: str) -> None: + if not isinstance(value, tuple) or not all(isinstance(item, str) and item for item in value): + raise EvidenceValidationError(f"{field} must be a tuple of nonempty text") + try: + canonical.require_unique_identities(value, kind=field) + except canonical.DuplicateIdentityError as exc: + raise EvidenceValidationError(str(exc)) from exc + + +@dataclass(frozen=True) +class SourceRegion: + path: str + start_line: int + end_line: int + + def __post_init__(self) -> None: + _require_text(self.path, "path") + for value, field in ((self.start_line, "start_line"), (self.end_line, "end_line")): + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise EvidenceValidationError(f"{field} must be a positive line number") + if self.end_line < self.start_line: + raise EvidenceValidationError("end_line must not precede start_line") + + +@dataclass(frozen=True) +class AnalyzedScope: + base_revision: str + head_revision: str + change_digest: str + regions: tuple[SourceRegion, ...] + + def __post_init__(self) -> None: + _require_text(self.base_revision, "base_revision") + _require_text(self.head_revision, "head_revision") + _require_digest(self.change_digest, "change_digest") + if not isinstance(self.regions, tuple) or not self.regions: + raise EvidenceValidationError("regions must be a nonempty tuple") + if not all(isinstance(region, SourceRegion) for region in self.regions): + raise EvidenceValidationError("every region must be a SourceRegion") + identities = tuple(f"{region.path}:{region.start_line}:{region.end_line}" for region in self.regions) + try: + canonical.require_unique_identities(identities, kind="region") + except canonical.DuplicateIdentityError as exc: + raise EvidenceValidationError(str(exc)) from exc + + +def _location_value(location: SourceLocation) -> canonical.JsonValue: + return {"path": location.path, "line": location.line, "column": location.column, "symbol": location.symbol} + + +def _node_value(node: SemanticNode) -> canonical.JsonValue: + return {"node_id": node.node_id, "role": node.role, "location": _location_value(node.location)} + + +def _content_ref_value(reference: canonical.ContentRef) -> canonical.JsonValue: + return { + "artifact_id": reference.artifact_id, + "content_digest": reference.content_digest, + "media_type": reference.media_type, + "byte_length": reference.byte_length, + "retention_policy_digest": reference.retention_policy_digest, + "storage_receipt_digest": reference.storage_receipt_digest, + } + + +def _semantic_provenance_value(value: EvidenceProvenance) -> canonical.JsonValue: + fact_match: canonical.JsonValue = None if value.fact_match is None else _content_ref_value(value.fact_match) + return { + "kind": value.kind.value, + "source_content_digest": value.source_content_digest, + "semantic_pack_digest": value.semantic_pack_digest, + "semantic_fact_id": value.semantic_fact_id, + "fact_match": fact_match, + } + + +def _field_value(value: FieldValue) -> canonical.JsonValue: + return {"name": value.name, "kind": value.kind.value, "value": value.value} + + +def _relation_value(value: SemanticRelation) -> canonical.JsonValue: + return { + "relation_id": value.relation_id, + "kind": value.kind.value, + "role": value.role, + "source": _node_value(value.source), + "target": _node_value(value.target), + "location": _location_value(value.location), + "status": value.status.value, + "conditions": value.conditions, + "reasons": value.reasons, + "provenance": _semantic_provenance_value(value.provenance), + } + + +def _observation_value(value: Observation) -> canonical.JsonValue: + return { + "observation_id": value.observation_id, + "role": value.role, + "location": _location_value(value.location), + "fields": tuple(_field_value(field) for field in value.fields), + "status": value.status.value, + "provenance": _semantic_provenance_value(value.provenance), + } + + +def _scope_value(value: AnalyzedScope) -> canonical.JsonValue: + return { + "base_revision": value.base_revision, + "head_revision": value.head_revision, + "change_digest": value.change_digest, + "regions": tuple({"path": region.path, "start_line": region.start_line, "end_line": region.end_line} for region in value.regions), + } + + +def _stamp_value(value: ProvenanceStamp) -> canonical.JsonValue: + return { + "analyzer_revision": value.analyzer_revision, + "family_contract_digest": value.family_contract_digest, + "fact_set_digest": value.fact_set_digest, + "acquisition_record_digest": value.acquisition_record_digest, + "configuration_digest": value.configuration_digest, + "population_digest": value.population_digest, + "change_input_digest": value.change_input_digest, + } + + +@dataclass(frozen=True) +class EvidencePacket: + schema_version: str + family_id: str + family_contract_digest: str + security_object_id: str + ecosystem: str + scope: AnalyzedScope + transitions: tuple[SemanticRelation, ...] + observations: tuple[Observation, ...] + family_fields: tuple[FieldValue, ...] + completeness_reasons: tuple[str, ...] + provenance: ProvenanceStamp + + def __post_init__(self) -> None: + _validate_packet(self) + + +def _validate_packet(packet: EvidencePacket) -> None: + if packet.schema_version != EVIDENCE_PACKET_SCHEMA: + raise EvidenceValidationError(f"unknown evidence packet schema: {packet.schema_version!r}") + for value, field in ( + (packet.family_id, "family_id"), + (packet.security_object_id, "security_object_id"), + (packet.ecosystem, "ecosystem"), + ): + _require_text(value, field) + _require_digest(packet.family_contract_digest, "family_contract_digest") + if not isinstance(packet.scope, AnalyzedScope): + raise EvidenceValidationError("scope must be an AnalyzedScope") + _validate_packet_members(packet) + _validate_completeness(packet) + if not isinstance(packet.provenance, ProvenanceStamp): + raise EvidenceValidationError("provenance must be a ProvenanceStamp") + if packet.provenance.family_contract_digest != packet.family_contract_digest: + raise EvidenceValidationError("packet family contract digest does not match provenance") + + +def _validate_packet_members(packet: EvidencePacket) -> None: + members = ( + (packet.transitions, SemanticRelation, "relation", "relation_id"), + (packet.observations, Observation, "observation", "observation_id"), + (packet.family_fields, FieldValue, "family field", "name"), + ) + for values, expected_type, kind, identity_field in members: + if not isinstance(values, tuple) or not all(isinstance(value, expected_type) for value in values): + raise EvidenceValidationError(f"{kind} values must be a tuple of {expected_type.__name__}") + try: + canonical.require_unique_identities(tuple(getattr(value, identity_field) for value in values), kind=kind) + except canonical.DuplicateIdentityError as exc: + raise EvidenceValidationError(str(exc)) from exc + + +def _validate_completeness(packet: EvidencePacket) -> None: + _require_text_tuple(packet.completeness_reasons, "completeness reason") + relations_incomplete = any(item.status is not RelationStatus.COMPLETE for item in packet.transitions) + observations_incomplete = any(item.status is not RelationStatus.COMPLETE for item in packet.observations) + if (relations_incomplete or observations_incomplete) and not packet.completeness_reasons: + raise EvidenceValidationError("non-complete semantics require named completeness reasons") + relation_reasons = {reason for relation in packet.transitions for reason in relation.reasons} + if not relation_reasons.issubset(packet.completeness_reasons): + raise EvidenceValidationError("every non-complete relation reason must be named in packet completeness") + + +def _packet_value(packet: EvidencePacket) -> canonical.JsonValue: + return { + "schema_version": packet.schema_version, + "family_id": packet.family_id, + "family_contract_digest": packet.family_contract_digest, + "security_object_id": packet.security_object_id, + "ecosystem": packet.ecosystem, + "scope": _scope_value(packet.scope), + "transitions": tuple(_relation_value(item) for item in packet.transitions), + "observations": tuple(_observation_value(item) for item in packet.observations), + "family_fields": tuple(_field_value(item) for item in packet.family_fields), + "completeness_reasons": packet.completeness_reasons, + "provenance": _stamp_value(packet.provenance), + } + + +def derive_packet_digest(packet: EvidencePacket) -> str: + if not isinstance(packet, EvidencePacket): + raise EvidenceValidationError("packet must be an EvidencePacket") + return canonical.content_digest(_packet_value(packet)) + + +def _model_identity_value(value: ModelCallIdentity) -> canonical.JsonValue: + return { + "provider": value.provider, + "model": value.model, + "program_digest": value.program_digest, + "prompt_digest": value.prompt_digest, + "configuration_digest": value.configuration_digest, + "adapter_revision": value.adapter_revision, + } + + +def _decision_provenance_value(value: DecisionProvenance) -> canonical.JsonValue: + model: canonical.JsonValue = None if value.model_identity is None else _model_identity_value(value.model_identity) + return { + "model_identity": model, + "model_result_digest": value.model_result_digest, + "model_declaration_digest": value.model_declaration_digest, + "proof_record_digest": value.proof_record_digest, + "proof_producer_digest": value.proof_producer_digest, + "lineage_manifest_digest": value.lineage_manifest_digest, + } + + +@dataclass(frozen=True) +class Decision: + family_code: str + outcome: NeutralOutcome + basis: EvidenceBasis + bounded_scope: AnalyzedScope + reasons: tuple[str, ...] + packet_digest: str + provenance: DecisionProvenance + + def __post_init__(self) -> None: + _require_text(self.family_code, "family_code") + if not isinstance(self.outcome, NeutralOutcome): + raise EvidenceValidationError("outcome must be a NeutralOutcome") + if not isinstance(self.basis, EvidenceBasis): + raise EvidenceValidationError("basis must be an EvidenceBasis") + if not isinstance(self.bounded_scope, AnalyzedScope): + raise EvidenceValidationError("bounded_scope must be an AnalyzedScope") + _require_text_tuple(self.reasons, "decision reason") + _require_digest(self.packet_digest, "packet_digest") + if not isinstance(self.provenance, DecisionProvenance): + raise EvidenceValidationError("provenance must be DecisionProvenance") + _validate_basis(self.basis, self.provenance) + + +def _validate_basis(basis: EvidenceBasis, value: DecisionProvenance) -> None: + model = (value.model_identity, value.model_result_digest, value.model_declaration_digest, value.lineage_manifest_digest) + proof = (value.proof_record_digest, value.proof_producer_digest) + if basis is EvidenceBasis.DETERMINISTIC and any(item is not None for item in (*model, *proof)): + raise EvidenceValidationError("deterministic basis forbids model, proof, and lineage provenance") + if basis is EvidenceBasis.MODEL_CORROBORATED and (any(item is None for item in model) or any(item is not None for item in proof)): + raise EvidenceValidationError("model-corroborated basis requires complete model provenance and forbids proof provenance") + if basis is EvidenceBasis.EXECUTION_BACKED and (any(item is None for item in proof) or any(item is not None for item in model)): + raise EvidenceValidationError("execution-backed basis requires complete proof provenance and forbids model provenance") + + +def _decision_value(decision: Decision) -> canonical.JsonValue: + return { + "family_code": decision.family_code, + "outcome": decision.outcome.value, + "basis": decision.basis.value, + "bounded_scope": _scope_value(decision.bounded_scope), + "reasons": decision.reasons, + "packet_digest": decision.packet_digest, + "provenance": _decision_provenance_value(decision.provenance), + } + + +def _scope_within(bounded: AnalyzedScope, analyzed: AnalyzedScope) -> bool: + if (bounded.base_revision, bounded.head_revision, bounded.change_digest) != ( + analyzed.base_revision, + analyzed.head_revision, + analyzed.change_digest, + ): + return False + return all(region in analyzed.regions for region in bounded.regions) + + +@dataclass(frozen=True) +class EvidenceRecord: + packet: EvidencePacket + decision: Decision + record_id: str + + def __post_init__(self) -> None: + if not isinstance(self.packet, EvidencePacket) or not isinstance(self.decision, Decision): + raise EvidenceValidationError("record requires an EvidencePacket and Decision") + _validate_record_pair(self.packet, self.decision) + expected = derive_record_id(self.packet, self.decision) + if not self.record_id: + object.__setattr__(self, "record_id", expected) + elif self.record_id != expected: + raise EvidenceValidationError("record_id does not match canonical record fields") + + +def _validate_record_pair(packet: EvidencePacket, decision: Decision) -> None: + if decision.packet_digest != derive_packet_digest(packet): + raise EvidenceValidationError("decision packet digest does not match packet") + if not _scope_within(decision.bounded_scope, packet.scope): + raise EvidenceValidationError("decision bounded scope is outside analyzed packet scope") + noncomplete = any(item.status is not RelationStatus.COMPLETE for item in packet.transitions) or any( + item.status is not RelationStatus.COMPLETE for item in packet.observations + ) + if decision.outcome is not NeutralOutcome.INDETERMINATE and (packet.completeness_reasons or noncomplete): + raise EvidenceValidationError("non-complete semantics or completeness reasons forbid a determinate outcome") + if decision.outcome is NeutralOutcome.INDETERMINATE: + if not decision.reasons: + raise EvidenceValidationError("indeterminate outcome requires a named reason") + if not set(packet.completeness_reasons).issubset(decision.reasons): + raise EvidenceValidationError("indeterminate reasons must include every packet completeness reason") + elif decision.reasons: + raise EvidenceValidationError("determinate outcomes must not carry indeterminate reasons") + + +def _record_value(packet: EvidencePacket, decision: Decision) -> canonical.JsonValue: + return { + "schema_version": EVIDENCE_RECORD_SCHEMA, + "packet": _packet_value(packet), + "decision": _decision_value(decision), + } + + +def derive_record_id(packet: EvidencePacket, decision: Decision) -> str: + return canonical.content_digest(_record_value(packet, decision)) + + +def evidence_record_bytes(record: EvidenceRecord) -> bytes: + if not isinstance(record, EvidenceRecord): + raise EvidenceValidationError("record must be an EvidenceRecord") + return canonical.canonical_bytes(_record_value(record.packet, record.decision)) + + +__all__ = [ + "AnalyzedScope", + "Decision", + "EVIDENCE_PACKET_SCHEMA", + "EVIDENCE_RECORD_SCHEMA", + "EvidenceBasis", + "EvidencePacket", + "EvidenceRecord", + "EvidenceValidationError", + "NeutralOutcome", + "SourceRegion", + "derive_packet_digest", + "derive_record_id", + "evidence_record_bytes", +] diff --git a/tests/test_core_evidence.py b/tests/test_core_evidence.py new file mode 100644 index 0000000..526ddf1 --- /dev/null +++ b/tests/test_core_evidence.py @@ -0,0 +1,349 @@ +"""Immutable evidence packet and neutral decision contracts.""" + +from __future__ import annotations + +import json +from dataclasses import fields, replace +from enum import Enum +from typing import cast + +import pytest + +import openultrasast.core.canonical as canonical +import openultrasast.core.evidence as evidence +import openultrasast.core.provenance as provenance +import openultrasast.core.semantics as semantics + + +def _digest(value: str) -> str: + return canonical.content_digest(value) + + +def _location(line: int = 7) -> semantics.SourceLocation: + return semantics.SourceLocation("src/app.py", line, 2, "handle") + + +def _product_provenance() -> semantics.EvidenceProvenance: + return semantics.EvidenceProvenance( + semantics.ProvenanceKind.PRODUCT_CODE, + _digest("source"), + None, + None, + None, + ) + + +def _relation(status: semantics.RelationStatus = semantics.RelationStatus.COMPLETE) -> semantics.SemanticRelation: + source = semantics.SemanticNode("request", "request", _location()) + target = semantics.SemanticNode("resource", "resource", _location(8)) + reasons = () if status is semantics.RelationStatus.COMPLETE else (f"relation:{status.value}",) + return semantics.make_relation( + kind=semantics.RelationKind.CONSTRAINT_EFFECT, + role="authorization_constraint", + source=source, + target=target, + location=_location(9), + status=status, + conditions=("authenticated",), + reasons=reasons, + provenance=_product_provenance(), + ) + + +def _observation(status: semantics.RelationStatus = semantics.RelationStatus.COMPLETE) -> semantics.Observation: + return semantics.make_observation( + role="security_object", + location=_location(10), + fields=(semantics.FieldValue("kind", semantics.FieldKind.TEXT, "resource"),), + status=status, + provenance=_product_provenance(), + ) + + +def _scope(*regions: evidence.SourceRegion) -> evidence.AnalyzedScope: + selected = regions or (evidence.SourceRegion("src/app.py", 1, 20),) + return evidence.AnalyzedScope("base", "head", _digest("change"), selected) + + +def _stamp() -> provenance.ProvenanceStamp: + return provenance.ProvenanceStamp( + *( + _digest(name) + for name in ( + "analyzer", + "family-contract", + "facts", + "acquisition", + "configuration", + "population", + "change-input", + ) + ) + ) + + +def _packet( + *, + status: semantics.RelationStatus = semantics.RelationStatus.COMPLETE, + completeness: tuple[str, ...] | None = None, + transitions: tuple[semantics.SemanticRelation, ...] | None = None, +) -> evidence.EvidencePacket: + relation = _relation(status) + reasons = () if status is semantics.RelationStatus.COMPLETE else relation.reasons + return evidence.EvidencePacket( + schema_version=evidence.EVIDENCE_PACKET_SCHEMA, + family_id="access-control", + family_contract_digest=_digest("family-contract"), + security_object_id="resource:42", + ecosystem="python", + scope=_scope(), + transitions=(relation,) if transitions is None else transitions, + observations=(_observation(),), + family_fields=(semantics.FieldValue("operation", semantics.FieldKind.TEXT, "read"),), + completeness_reasons=reasons if completeness is None else completeness, + provenance=_stamp(), + ) + + +def _empty_decision_provenance() -> provenance.DecisionProvenance: + return provenance.DecisionProvenance(None, None, None, None, None, None) + + +def _decision( + packet: evidence.EvidencePacket, + *, + outcome: evidence.NeutralOutcome = evidence.NeutralOutcome.VIOLATION, + basis: evidence.EvidenceBasis = evidence.EvidenceBasis.DETERMINISTIC, + reasons: tuple[str, ...] = (), + bounded_scope: evidence.AnalyzedScope | None = None, + decision_provenance: provenance.DecisionProvenance | None = None, +) -> evidence.Decision: + return evidence.Decision( + family_code="unauthorized_access", + outcome=outcome, + basis=basis, + bounded_scope=packet.scope if bounded_scope is None else bounded_scope, + reasons=reasons, + packet_digest=evidence.derive_packet_digest(packet), + provenance=_empty_decision_provenance() if decision_provenance is None else decision_provenance, + ) + + +def _record( + *, + packet: evidence.EvidencePacket | None = None, + outcome: evidence.NeutralOutcome = evidence.NeutralOutcome.VIOLATION, + basis: evidence.EvidenceBasis = evidence.EvidenceBasis.DETERMINISTIC, + reasons: tuple[str, ...] = (), + bounded_scope: evidence.AnalyzedScope | None = None, + decision_provenance: provenance.DecisionProvenance | None = None, +) -> evidence.EvidenceRecord: + selected = _packet() if packet is None else packet + decision = _decision( + selected, + outcome=outcome, + basis=basis, + reasons=reasons, + bounded_scope=bounded_scope, + decision_provenance=decision_provenance, + ) + return evidence.EvidenceRecord(selected, decision, "") + + +@pytest.mark.parametrize("outcome", [evidence.NeutralOutcome.VIOLATION, evidence.NeutralOutcome.SATISFIED]) +def test_determinate_records_round_trip_canonically(outcome: evidence.NeutralOutcome) -> None: + record = _record(outcome=outcome) + content = evidence.evidence_record_bytes(record) + parsed = json.loads(content) + + assert len(content) > 0 + assert json.dumps(parsed, ensure_ascii=False, allow_nan=False, sort_keys=True, separators=(",", ":")).encode() == content + assert record.record_id == canonical.bytes_digest(content) + assert parsed["decision"]["family_code"] == "unauthorized_access" + assert parsed["decision"]["outcome"] == outcome.value + assert parsed["packet"]["family_id"] == "access-control" + + +def test_incomplete_record_round_trips_as_named_indeterminate() -> None: + packet = _packet(status=semantics.RelationStatus.INCOMPLETE) + record = _record( + packet=packet, + outcome=evidence.NeutralOutcome.INDETERMINATE, + reasons=packet.completeness_reasons, + ) + parsed = json.loads(evidence.evidence_record_bytes(record)) + + assert parsed["decision"]["outcome"] == "indeterminate" + assert parsed["decision"]["reasons"] == ["relation:incomplete"] + + +def test_packet_preserves_ordered_semantics_and_family_fields() -> None: + first = _relation() + second = replace(first, location=_location(11), relation_id="") + forward = _packet(transitions=(first, second)) + reverse = _packet(transitions=(second, first)) + + assert forward.transitions == (first, second) + assert evidence.derive_packet_digest(forward) != evidence.derive_packet_digest(reverse) + assert forward.family_fields[0].name == "operation" + + +@pytest.mark.parametrize("status", [semantics.RelationStatus.INCOMPLETE, semantics.RelationStatus.UNRESOLVED]) +@pytest.mark.parametrize("outcome", [evidence.NeutralOutcome.VIOLATION, evidence.NeutralOutcome.SATISFIED]) +def test_noncomplete_semantics_cannot_produce_determinate_outcome( + status: semantics.RelationStatus, + outcome: evidence.NeutralOutcome, +) -> None: + packet = _packet(status=status) + with pytest.raises(evidence.EvidenceValidationError, match="non-complete|completeness"): + _record(packet=packet, outcome=outcome) + + +def test_packet_requires_every_noncomplete_reason_to_be_named() -> None: + with pytest.raises(evidence.EvidenceValidationError, match="completeness"): + _packet(status=semantics.RelationStatus.INCOMPLETE, completeness=()) + + +def test_noncomplete_observation_also_forces_indeterminate_outcome() -> None: + packet = replace( + _packet(), + observations=(_observation(semantics.RelationStatus.UNRESOLVED),), + completeness_reasons=("observation:unresolved",), + ) + with pytest.raises(evidence.EvidenceValidationError, match="non-complete"): + _record(packet=packet, outcome=evidence.NeutralOutcome.VIOLATION) + + +def test_indeterminate_requires_reasons_and_carries_every_packet_reason() -> None: + packet = _packet(status=semantics.RelationStatus.INCOMPLETE) + with pytest.raises(evidence.EvidenceValidationError, match="reason"): + _record(packet=packet, outcome=evidence.NeutralOutcome.INDETERMINATE) + with pytest.raises(evidence.EvidenceValidationError, match="completeness"): + _record(packet=packet, outcome=evidence.NeutralOutcome.INDETERMINATE, reasons=("different",)) + + +def test_satisfaction_requires_nonempty_bounded_scope_within_packet_scope() -> None: + packet = _packet() + with pytest.raises(evidence.EvidenceValidationError, match="bounded scope"): + _record( + packet=packet, + outcome=evidence.NeutralOutcome.SATISFIED, + bounded_scope=_scope(evidence.SourceRegion("src/other.py", 1, 2)), + ) + outside = evidence.AnalyzedScope("base", "head", _digest("other-change"), packet.scope.regions) + with pytest.raises(evidence.EvidenceValidationError, match="bounded scope"): + _record(packet=packet, outcome=evidence.NeutralOutcome.SATISFIED, bounded_scope=outside) + + +def test_satisfaction_accepts_an_exact_nonempty_subset_scope() -> None: + packet = replace( + _packet(), + scope=_scope(evidence.SourceRegion("src/app.py", 1, 10), evidence.SourceRegion("src/app.py", 20, 30)), + ) + bounded = _scope(evidence.SourceRegion("src/app.py", 1, 10)) + record = _record(packet=packet, outcome=evidence.NeutralOutcome.SATISFIED, bounded_scope=bounded) + assert record.decision.bounded_scope == bounded + + +def _model_provenance() -> provenance.DecisionProvenance: + identity = provenance.ModelCallIdentity( + provider="provider", + model="model", + program_digest=_digest("program"), + prompt_digest=_digest("prompt"), + configuration_digest=_digest("model-configuration"), + adapter_revision="adapter-v1", + ) + return provenance.DecisionProvenance( + identity, + _digest("model-result"), + _digest("model-declaration"), + None, + None, + _digest("lineage"), + ) + + +def _proof_provenance() -> provenance.DecisionProvenance: + return provenance.DecisionProvenance(None, None, None, _digest("proof"), _digest("producer"), None) + + +@pytest.mark.parametrize( + ("basis", "decision_provenance"), + [ + (evidence.EvidenceBasis.DETERMINISTIC, _empty_decision_provenance()), + (evidence.EvidenceBasis.MODEL_CORROBORATED, _model_provenance()), + (evidence.EvidenceBasis.EXECUTION_BACKED, _proof_provenance()), + ], +) +def test_every_closed_evidence_basis_accepts_only_its_complete_provenance( + basis: evidence.EvidenceBasis, + decision_provenance: provenance.DecisionProvenance, +) -> None: + record = _record(basis=basis, decision_provenance=decision_provenance) + assert record.decision.basis is basis + + +@pytest.mark.parametrize( + ("basis", "decision_provenance"), + [ + (evidence.EvidenceBasis.DETERMINISTIC, _model_provenance()), + (evidence.EvidenceBasis.DETERMINISTIC, _proof_provenance()), + (evidence.EvidenceBasis.MODEL_CORROBORATED, _empty_decision_provenance()), + (evidence.EvidenceBasis.MODEL_CORROBORATED, replace(_model_provenance(), model_result_digest=None)), + (evidence.EvidenceBasis.EXECUTION_BACKED, _empty_decision_provenance()), + (evidence.EvidenceBasis.EXECUTION_BACKED, replace(_proof_provenance(), proof_producer_digest=None)), + (evidence.EvidenceBasis.EXECUTION_BACKED, _model_provenance()), + ], +) +def test_basis_provenance_is_a_closed_union( + basis: evidence.EvidenceBasis, + decision_provenance: provenance.DecisionProvenance, +) -> None: + with pytest.raises(evidence.EvidenceValidationError, match="basis|provenance"): + _record(basis=basis, decision_provenance=decision_provenance) + + +def test_packet_has_no_model_or_proof_fields() -> None: + names = {item.name for item in fields(evidence.EvidencePacket)} + assert not any("model" in name or "proof" in name for name in names) + assert {"model_identity", "proof_record_digest"} <= {item.name for item in fields(provenance.DecisionProvenance)} + + +def test_decision_is_bound_to_exact_packet_digest_and_record_identity() -> None: + record = _record() + with pytest.raises(evidence.EvidenceValidationError, match="packet digest"): + replace(record, decision=replace(record.decision, packet_digest=_digest("other")), record_id="") + with pytest.raises(evidence.EvidenceValidationError, match="record_id"): + replace(record, record_id="0" * 64) + + +def test_packet_rejects_duplicate_logical_identities() -> None: + relation = _relation() + with pytest.raises(evidence.EvidenceValidationError, match="duplicate"): + _packet(transitions=(relation, relation)) + packet = _packet() + with pytest.raises(evidence.EvidenceValidationError, match="duplicate"): + replace(packet, family_fields=(packet.family_fields[0], packet.family_fields[0])) + + +def test_scope_and_text_contracts_are_runtime_checked() -> None: + with pytest.raises(evidence.EvidenceValidationError, match="line"): + evidence.SourceRegion("src/app.py", 9, 8) + with pytest.raises(evidence.EvidenceValidationError, match="region"): + evidence.AnalyzedScope("base", "head", _digest("change"), ()) + packet = _packet() + with pytest.raises(evidence.EvidenceValidationError, match="family_id"): + replace(packet, family_id=cast(str, 7)) + + +def test_unknown_runtime_enums_and_schema_are_refused() -> None: + packet = _packet() + with pytest.raises(evidence.EvidenceValidationError, match="schema"): + replace(packet, schema_version="unknown") + + class ForeignOutcome(Enum): + VIOLATION = "violation" + + with pytest.raises(evidence.EvidenceValidationError, match="NeutralOutcome"): + replace(_decision(packet), outcome=cast(evidence.NeutralOutcome, ForeignOutcome.VIOLATION))