mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-11-13 01:48:17 +00:00
feat(detection-core): replay fact-derived relations
This commit is contained in:
@@ -146,7 +146,7 @@
|
|||||||
- Mark label-influenced matching behavior or promotion as spent before the relation can participate in evaluation.
|
- Mark label-influenced matching behavior or promotion as spent before the relation can participate in evaluation.
|
||||||
- Done when valid matches yield canonical effects while partial and ambiguous cases remain non-determinate with named reasons.
|
- Done when valid matches yield canonical effects while partial and ambiguous cases remain non-determinate with named reasons.
|
||||||
- _Requirements: 4.1, 4.2, 4.3, 10.2, 11.2, 11.3, 11.5, 13.4_
|
- _Requirements: 4.1, 4.2, 4.3, 10.2, 11.2, 11.3, 11.5, 13.4_
|
||||||
- [ ] 5.4 Revalidate fact-derived relations and enforce the dependency boundary
|
- [x] 5.4 Revalidate fact-derived relations and enforce the dependency boundary
|
||||||
- Re-open referenced packs, facts, match evidence, review policy, lineage, custody, and resolved bytes for every fact-derived relation.
|
- Re-open referenced packs, facts, match evidence, review policy, lineage, custody, and resolved bytes for every fact-derived relation.
|
||||||
- Reject undeclared roles, invalid scope, inconsistent content, and fact provenance that cannot be reproduced.
|
- Reject undeclared roles, invalid scope, inconsistent content, and fact provenance that cannot be reproduced.
|
||||||
- Require reviewed semantic facts for determinate behavior attributed to excluded dependencies.
|
- Require reviewed semantic facts for determinate behavior attributed to excluded dependencies.
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ from types import MappingProxyType
|
|||||||
from openultrasast.core import _factpack_digest as digest_projection
|
from openultrasast.core import _factpack_digest as digest_projection
|
||||||
from openultrasast.core import canonical
|
from openultrasast.core import canonical
|
||||||
from openultrasast.core._legacy_factpack import FactPackError, admit_fact_pack, admit_pack_file, load_pack_file, verify_fact_pack
|
from openultrasast.core._legacy_factpack import FactPackError, admit_fact_pack, admit_pack_file, load_pack_file, verify_fact_pack
|
||||||
from openultrasast.core.custody import LineagePurpose, PopulationCustodyPolicy, PopulationCustodyPort
|
from openultrasast.core.custody import (
|
||||||
|
LineagePurpose,
|
||||||
|
PopulationCustodyPolicy,
|
||||||
|
PopulationCustodyPort,
|
||||||
|
PopulationUseReceipt,
|
||||||
|
validate_population_custody_log,
|
||||||
|
)
|
||||||
from openultrasast.core.family import FamilyContract, family_contract_digest, validate_contract
|
from openultrasast.core.family import FamilyContract, family_contract_digest, validate_contract
|
||||||
from openultrasast.core.lineage import LineageExpectation, LineageManifest, validate_lineage
|
from openultrasast.core.lineage import LineageExpectation, LineageManifest, validate_lineage
|
||||||
from openultrasast.core.semantics import RelationKind
|
from openultrasast.core.semantics import RelationKind
|
||||||
@@ -366,6 +372,49 @@ def _lineage_for(evidence: KnowledgeEvidence, lineages: Sequence[LineageManifest
|
|||||||
return matches[0]
|
return matches[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _authoritative_semantic_fact_receipts(
|
||||||
|
selected_lineage: LineageManifest,
|
||||||
|
lineages: Sequence[LineageManifest],
|
||||||
|
pack: FactPack,
|
||||||
|
protected_population_digest: str | None,
|
||||||
|
custody: PopulationCustodyPort,
|
||||||
|
custody_policy: PopulationCustodyPolicy,
|
||||||
|
) -> tuple[PopulationUseReceipt, ...]:
|
||||||
|
populations = {use.source_population_digest for lineage in lineages for use in lineage.uses}
|
||||||
|
if protected_population_digest is not None:
|
||||||
|
populations.add(protected_population_digest)
|
||||||
|
relevant: list[PopulationUseReceipt] = []
|
||||||
|
for population_digest in sorted(populations):
|
||||||
|
log = custody.current_log(population_digest)
|
||||||
|
validate_population_custody_log(log, custody_policy, current=custody)
|
||||||
|
relevant.extend(
|
||||||
|
receipt
|
||||||
|
for receipt in log.receipts
|
||||||
|
if receipt.request.purpose is LineagePurpose.SEMANTIC_FACT
|
||||||
|
and receipt.request.influenced_artifact_digest == pack.normalized_content_digest
|
||||||
|
and (receipt.request.arm_id == selected_lineage.arm_id or receipt.request.labels_accessed)
|
||||||
|
)
|
||||||
|
return tuple(relevant)
|
||||||
|
|
||||||
|
|
||||||
|
def _refuse_omitted_semantic_fact_uses(
|
||||||
|
lineage: LineageManifest,
|
||||||
|
receipts: tuple[PopulationUseReceipt, ...],
|
||||||
|
protected_population_digest: str | None,
|
||||||
|
) -> None:
|
||||||
|
declared = {use.population_use_receipt_digest for use in lineage.uses}
|
||||||
|
for receipt in receipts:
|
||||||
|
if receipt.receipt_digest in declared:
|
||||||
|
continue
|
||||||
|
request = receipt.request
|
||||||
|
if request.labels_accessed:
|
||||||
|
reasons = [f"label_access_forbidden:{request.population_digest}"]
|
||||||
|
if request.population_digest == protected_population_digest:
|
||||||
|
reasons.append(f"protected_population_labels:{request.population_digest}")
|
||||||
|
raise FactPackValidationError("semantic fact lineage is contaminated: " + ",".join(reasons))
|
||||||
|
raise FactPackValidationError(f"semantic fact lineage omitted current use: {receipt.receipt_digest}")
|
||||||
|
|
||||||
|
|
||||||
def _validate_evidence_lineage(
|
def _validate_evidence_lineage(
|
||||||
pack: FactPack,
|
pack: FactPack,
|
||||||
evidence: KnowledgeEvidence,
|
evidence: KnowledgeEvidence,
|
||||||
@@ -390,6 +439,15 @@ def _validate_evidence_lineage(
|
|||||||
custody,
|
custody,
|
||||||
custody_policy,
|
custody_policy,
|
||||||
)
|
)
|
||||||
|
current_receipts = _authoritative_semantic_fact_receipts(
|
||||||
|
lineage,
|
||||||
|
lineages,
|
||||||
|
pack,
|
||||||
|
protected_population_digest,
|
||||||
|
custody,
|
||||||
|
custody_policy,
|
||||||
|
)
|
||||||
|
_refuse_omitted_semantic_fact_uses(lineage, current_receipts, protected_population_digest)
|
||||||
if not decision.allowed:
|
if not decision.allowed:
|
||||||
raise FactPackValidationError("fact lineage refused: " + ",".join(decision.reasons))
|
raise FactPackValidationError("fact lineage refused: " + ",".join(decision.reasons))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"""Replay validation for fact-derived relations and dependency boundaries."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from openultrasast.core import canonical
|
||||||
|
from openultrasast.core.custody import PopulationCustodyPolicy, PopulationCustodyPort
|
||||||
|
from openultrasast.core.evidence import EvidenceRecord
|
||||||
|
from openultrasast.core.factapply import apply_fact
|
||||||
|
from openultrasast.core.factmatch import FACT_MATCH_SCHEMA, FactMatchEvidence, StructuralObservation
|
||||||
|
from openultrasast.core.factpack import FactPack, ReviewIndependencePolicy
|
||||||
|
from openultrasast.core.family import FamilyContract, validate_record
|
||||||
|
from openultrasast.core.lineage import LineageManifest
|
||||||
|
from openultrasast.core.semantics import ProvenanceKind, SemanticRelation, SourceLocation
|
||||||
|
|
||||||
|
|
||||||
|
class FactRelationValidationError(ValueError):
|
||||||
|
"""A fact-derived relation cannot be reproduced from its named inputs."""
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping(value: object, keys: set[str], field: str) -> Mapping[str, object]:
|
||||||
|
if not isinstance(value, Mapping) or not all(isinstance(key, str) for key in value) or set(value) != keys:
|
||||||
|
raise FactRelationValidationError(f"{field} must be an exact canonical object")
|
||||||
|
return cast(Mapping[str, object], value)
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: object, field: str) -> str:
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
raise FactRelationValidationError(f"{field} must be nonempty text")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _integer(value: object, field: str, *, minimum: int) -> int:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
||||||
|
raise FactRelationValidationError(f"{field} must be an integer of at least {minimum}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_text(value: object, field: str) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return _text(value, field)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_integer(value: object, field: str) -> int | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return _integer(value, field, minimum=0)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_value(value: object) -> canonical.JsonValue:
|
||||||
|
if value is None or isinstance(value, (str, bool, int, float)):
|
||||||
|
canonical.canonical_bytes(value)
|
||||||
|
return value
|
||||||
|
if isinstance(value, list):
|
||||||
|
return tuple(_json_value(item) for item in value)
|
||||||
|
if isinstance(value, Mapping) and all(isinstance(key, str) for key in value):
|
||||||
|
return {cast(str, key): _json_value(item) for key, item in value.items()}
|
||||||
|
raise FactRelationValidationError("match artifact contains a value outside the canonical JSON profile")
|
||||||
|
|
||||||
|
|
||||||
|
def _location(value: object, field: str) -> SourceLocation:
|
||||||
|
item = _mapping(value, {"path", "line", "column", "symbol"}, field)
|
||||||
|
return SourceLocation(
|
||||||
|
_text(item["path"], f"{field}.path"),
|
||||||
|
_integer(item["line"], f"{field}.line", minimum=1),
|
||||||
|
_optional_integer(item["column"], f"{field}.column"),
|
||||||
|
_optional_text(item["symbol"], f"{field}.symbol"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reference(value: object, field: str) -> canonical.ContentRef:
|
||||||
|
item = _mapping(
|
||||||
|
value,
|
||||||
|
{"artifact_id", "content_digest", "media_type", "byte_length", "retention_policy_digest", "storage_receipt_digest"},
|
||||||
|
field,
|
||||||
|
)
|
||||||
|
return canonical.ContentRef(
|
||||||
|
_text(item["artifact_id"], f"{field}.artifact_id"),
|
||||||
|
_text(item["content_digest"], f"{field}.content_digest"),
|
||||||
|
_text(item["media_type"], f"{field}.media_type"),
|
||||||
|
_integer(item["byte_length"], f"{field}.byte_length", minimum=0),
|
||||||
|
_text(item["retention_policy_digest"], f"{field}.retention_policy_digest"),
|
||||||
|
_text(item["storage_receipt_digest"], f"{field}.storage_receipt_digest"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _observation(value: object, index: int) -> StructuralObservation:
|
||||||
|
field = f"observations[{index}]"
|
||||||
|
item = _mapping(value, {"requirement_id", "location", "observed", "evidence"}, field)
|
||||||
|
return StructuralObservation(
|
||||||
|
_text(item["requirement_id"], f"{field}.requirement_id"),
|
||||||
|
_location(item["location"], f"{field}.location"),
|
||||||
|
_json_value(item["observed"]),
|
||||||
|
_reference(item["evidence"], f"{field}.evidence"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _match_from_bytes(content: bytes) -> FactMatchEvidence:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(content.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise FactRelationValidationError(f"match artifact is not canonical JSON: {exc}") from exc
|
||||||
|
normalized = _json_value(parsed)
|
||||||
|
if canonical.canonical_bytes(normalized) != content:
|
||||||
|
raise FactRelationValidationError("match artifact bytes are not canonical")
|
||||||
|
item = _mapping(
|
||||||
|
parsed,
|
||||||
|
{
|
||||||
|
"schema_version",
|
||||||
|
"pack_digest",
|
||||||
|
"fact_id",
|
||||||
|
"product_location",
|
||||||
|
"observations",
|
||||||
|
"matcher_revision",
|
||||||
|
"matcher_behavior_digest",
|
||||||
|
"matcher_lineage_manifest_digest",
|
||||||
|
},
|
||||||
|
"match artifact",
|
||||||
|
)
|
||||||
|
if item["schema_version"] != FACT_MATCH_SCHEMA:
|
||||||
|
raise FactRelationValidationError("match artifact has an unknown schema version")
|
||||||
|
observations = item["observations"]
|
||||||
|
if not isinstance(observations, list):
|
||||||
|
raise FactRelationValidationError("match observations must be an array")
|
||||||
|
return FactMatchEvidence(
|
||||||
|
_text(item["pack_digest"], "match.pack_digest"),
|
||||||
|
_text(item["fact_id"], "match.fact_id"),
|
||||||
|
_location(item["product_location"], "match.product_location"),
|
||||||
|
tuple(_observation(value, index) for index, value in enumerate(observations)),
|
||||||
|
_text(item["matcher_revision"], "match.matcher_revision"),
|
||||||
|
_text(item["matcher_behavior_digest"], "match.matcher_behavior_digest"),
|
||||||
|
_text(item["matcher_lineage_manifest_digest"], "match.matcher_lineage_manifest_digest"),
|
||||||
|
canonical.bytes_digest(content),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_for(relation: SemanticRelation, packs: Sequence[FactPack]) -> FactPack:
|
||||||
|
digest = relation.provenance.semantic_pack_digest
|
||||||
|
matches = tuple(pack for pack in packs if pack.normalized_content_digest == digest)
|
||||||
|
if len(matches) != 1:
|
||||||
|
raise FactRelationValidationError("semantic pack identity is missing or ambiguous")
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _replay_relation(
|
||||||
|
relation: SemanticRelation,
|
||||||
|
record: EvidenceRecord,
|
||||||
|
contract: FamilyContract,
|
||||||
|
packs: Sequence[FactPack],
|
||||||
|
population_digest: str,
|
||||||
|
lineages: Sequence[LineageManifest],
|
||||||
|
custody: PopulationCustodyPort,
|
||||||
|
custody_policy: PopulationCustodyPolicy,
|
||||||
|
review_policy: ReviewIndependencePolicy,
|
||||||
|
resolver: canonical.ContentResolver,
|
||||||
|
) -> None:
|
||||||
|
pack = _pack_for(relation, packs)
|
||||||
|
if (pack.family_id, pack.family_contract_digest, pack.ecosystem) != (
|
||||||
|
record.packet.family_id,
|
||||||
|
record.packet.family_contract_digest,
|
||||||
|
record.packet.ecosystem,
|
||||||
|
):
|
||||||
|
raise FactRelationValidationError("semantic pack scope does not match the evidence record")
|
||||||
|
reference = relation.provenance.fact_match
|
||||||
|
if reference is None or relation.provenance.semantic_fact_id is None:
|
||||||
|
raise FactRelationValidationError("semantic fact provenance is incomplete")
|
||||||
|
content = canonical.resolve_content(
|
||||||
|
reference,
|
||||||
|
resolver,
|
||||||
|
trusted_store_identities=review_policy.trusted_store_identities,
|
||||||
|
maximum_bytes=review_policy.maximum_evidence_bytes,
|
||||||
|
)
|
||||||
|
match = _match_from_bytes(content)
|
||||||
|
replayed = apply_fact(
|
||||||
|
pack,
|
||||||
|
relation.provenance.semantic_fact_id,
|
||||||
|
match,
|
||||||
|
reference,
|
||||||
|
contract,
|
||||||
|
population_digest,
|
||||||
|
lineages,
|
||||||
|
custody,
|
||||||
|
custody_policy,
|
||||||
|
review_policy,
|
||||||
|
resolver,
|
||||||
|
)
|
||||||
|
if relation not in replayed:
|
||||||
|
raise FactRelationValidationError("fact-derived relation does not equal replayed declared effect")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_fact_relations(
|
||||||
|
record: EvidenceRecord,
|
||||||
|
contract: FamilyContract,
|
||||||
|
packs: Sequence[FactPack],
|
||||||
|
evaluation_population_digest: str,
|
||||||
|
lineages: Sequence[LineageManifest],
|
||||||
|
custody: PopulationCustodyPort,
|
||||||
|
custody_policy: PopulationCustodyPolicy,
|
||||||
|
review_policy: ReviewIndependencePolicy,
|
||||||
|
resolver: canonical.ContentResolver,
|
||||||
|
) -> None:
|
||||||
|
"""Re-open and replay every fact-derived relation in an evidence record."""
|
||||||
|
try:
|
||||||
|
validate_record(contract, record)
|
||||||
|
if record.packet.provenance.population_digest != evaluation_population_digest:
|
||||||
|
raise FactRelationValidationError("record has an invalid evaluation population scope")
|
||||||
|
if not isinstance(packs, Sequence) or not all(isinstance(pack, FactPack) for pack in packs):
|
||||||
|
raise FactRelationValidationError("packs must be a sequence of FactPack values")
|
||||||
|
for relation in record.packet.transitions:
|
||||||
|
if relation.provenance.kind is ProvenanceKind.SEMANTIC_FACT:
|
||||||
|
_replay_relation(
|
||||||
|
relation,
|
||||||
|
record,
|
||||||
|
contract,
|
||||||
|
packs,
|
||||||
|
evaluation_population_digest,
|
||||||
|
lineages,
|
||||||
|
custody,
|
||||||
|
custody_policy,
|
||||||
|
review_policy,
|
||||||
|
resolver,
|
||||||
|
)
|
||||||
|
except FactRelationValidationError:
|
||||||
|
raise
|
||||||
|
except (LookupError, TypeError, ValueError) as exc:
|
||||||
|
raise FactRelationValidationError(f"fact relation replay failed: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["FactRelationValidationError", "validate_fact_relations"]
|
||||||
@@ -7,7 +7,7 @@ from dataclasses import dataclass, replace
|
|||||||
import pytest
|
import pytest
|
||||||
from detection_core_fixtures import InMemoryContentResolver, InMemoryPopulationCustody, custody_contract, stored_artifact
|
from detection_core_fixtures import InMemoryContentResolver, InMemoryPopulationCustody, custody_contract, stored_artifact
|
||||||
|
|
||||||
from openultrasast.core import canonical, provenance
|
from openultrasast.core import canonical, evidence, provenance
|
||||||
from openultrasast.core.evidence import NeutralOutcome
|
from openultrasast.core.evidence import NeutralOutcome
|
||||||
from openultrasast.core.factapply import FactApplicationError, apply_fact
|
from openultrasast.core.factapply import FactApplicationError, apply_fact
|
||||||
from openultrasast.core.factmatch import (
|
from openultrasast.core.factmatch import (
|
||||||
@@ -34,7 +34,9 @@ from openultrasast.core.factpack import (
|
|||||||
derive_review_evidence_digest,
|
derive_review_evidence_digest,
|
||||||
derive_review_independence_policy_digest,
|
derive_review_independence_policy_digest,
|
||||||
)
|
)
|
||||||
|
from openultrasast.core.factvalidate import FactRelationValidationError, validate_fact_relations
|
||||||
from openultrasast.core.family import (
|
from openultrasast.core.family import (
|
||||||
|
MISSING_REVIEWED_DEPENDENCY,
|
||||||
DecisionRule,
|
DecisionRule,
|
||||||
FamilyContract,
|
FamilyContract,
|
||||||
FieldSpec,
|
FieldSpec,
|
||||||
@@ -42,15 +44,20 @@ from openultrasast.core.family import (
|
|||||||
RelationOriginPolicy,
|
RelationOriginPolicy,
|
||||||
ResidualQuestion,
|
ResidualQuestion,
|
||||||
RoleSpec,
|
RoleSpec,
|
||||||
|
derive_security_object_id,
|
||||||
family_contract_digest,
|
family_contract_digest,
|
||||||
)
|
)
|
||||||
from openultrasast.core.semantics import (
|
from openultrasast.core.semantics import (
|
||||||
|
EvidenceProvenance,
|
||||||
FieldKind,
|
FieldKind,
|
||||||
|
FieldValue,
|
||||||
ProvenanceKind,
|
ProvenanceKind,
|
||||||
RelationKind,
|
RelationKind,
|
||||||
RelationStatus,
|
RelationStatus,
|
||||||
|
SemanticNode,
|
||||||
SemanticRelation,
|
SemanticRelation,
|
||||||
SourceLocation,
|
SourceLocation,
|
||||||
|
make_relation,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -68,21 +75,47 @@ class ApplyCase:
|
|||||||
population_digest: str
|
population_digest: str
|
||||||
|
|
||||||
|
|
||||||
def _contract() -> FamilyContract:
|
class MultiPopulationCustody(InMemoryPopulationCustody):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
logs: tuple[provenance.PopulationCustodyLog, ...],
|
||||||
|
policy: provenance.PopulationCustodyPolicy,
|
||||||
|
) -> None:
|
||||||
|
self._logs = {log.population_digest: log for log in logs}
|
||||||
|
self._multi_policy = policy
|
||||||
|
|
||||||
|
def current_log(self, population_digest: str) -> provenance.PopulationCustodyLog:
|
||||||
|
try:
|
||||||
|
return self._logs[population_digest]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise provenance.PopulationCustodyError("population custody log not found") from exc
|
||||||
|
|
||||||
|
def append(self, request: provenance.PopulationUseRequest, custodian: str) -> provenance.PopulationUseReceipt:
|
||||||
|
prior = self.current_log(request.population_digest)
|
||||||
|
receipt = provenance.record_population_use(request, prior, custodian, self._multi_policy)
|
||||||
|
self._logs[request.population_digest] = provenance.append_population_use(prior, receipt, self._multi_policy)
|
||||||
|
return receipt
|
||||||
|
|
||||||
|
|
||||||
|
def _contract(*, fact_only: bool = False) -> FamilyContract:
|
||||||
|
origin = RelationOriginPolicy.EXCLUDED_DEPENDENCY_FACT_ONLY if fact_only else RelationOriginPolicy.PRODUCT_OR_REVIEWED_FACT
|
||||||
return FamilyContract(
|
return FamilyContract(
|
||||||
"reference-family",
|
"reference-family",
|
||||||
"1",
|
"1",
|
||||||
"family.v2",
|
evidence.EVIDENCE_PACKET_SCHEMA,
|
||||||
("python",),
|
("python",),
|
||||||
ObjectSpec("resource", (FieldSpec("name", FieldKind.TEXT, True),), ("name",)),
|
ObjectSpec("resource", (FieldSpec("name", FieldKind.TEXT, True),), ("name",)),
|
||||||
(
|
(
|
||||||
RoleSpec("flow", (), (RelationKind.CALL,), RelationOriginPolicy.PRODUCT_OR_REVIEWED_FACT),
|
RoleSpec("flow", (), (RelationKind.CALL,), origin),
|
||||||
RoleSpec("source", (FieldSpec("name", FieldKind.TEXT, False),), (), RelationOriginPolicy.PRODUCT_OR_REVIEWED_FACT),
|
RoleSpec("source", (FieldSpec("name", FieldKind.TEXT, False),), (), RelationOriginPolicy.PRODUCT_OR_REVIEWED_FACT),
|
||||||
RoleSpec("target", (FieldSpec("name", FieldKind.TEXT, False),), (), RelationOriginPolicy.PRODUCT_OR_REVIEWED_FACT),
|
RoleSpec("target", (FieldSpec("name", FieldKind.TEXT, False),), (), RelationOriginPolicy.PRODUCT_OR_REVIEWED_FACT),
|
||||||
),
|
),
|
||||||
(DecisionRule("unknown", NeutralOutcome.INDETERMINATE, (), (RelationStatus.UNRESOLVED,)),),
|
(
|
||||||
(ResidualQuestion("classify", ("flow",), ("unknown",)),),
|
DecisionRule("safe", NeutralOutcome.SATISFIED, ("flow",), (RelationStatus.COMPLETE,)),
|
||||||
("missing",),
|
DecisionRule("unknown", NeutralOutcome.INDETERMINATE, (), (RelationStatus.INCOMPLETE, RelationStatus.UNRESOLVED)),
|
||||||
|
),
|
||||||
|
(ResidualQuestion("classify", ("flow",), ("safe", "unknown")),),
|
||||||
|
("missing", MISSING_REVIEWED_DEPENDENCY),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -94,8 +127,9 @@ def _append_use(
|
|||||||
*,
|
*,
|
||||||
labels_accessed: bool,
|
labels_accessed: bool,
|
||||||
arm_id: str,
|
arm_id: str,
|
||||||
|
population_digest: str | None = None,
|
||||||
) -> provenance.LineageUse:
|
) -> provenance.LineageUse:
|
||||||
current = custody.current_log(canonical.content_digest("population"))
|
current = custody.current_log(population_digest or canonical.content_digest("population"))
|
||||||
request = provenance.PopulationUseRequest(
|
request = provenance.PopulationUseRequest(
|
||||||
current.population_digest,
|
current.population_digest,
|
||||||
arm_id,
|
arm_id,
|
||||||
@@ -152,8 +186,9 @@ def _case(
|
|||||||
matcher_labels: bool = False,
|
matcher_labels: bool = False,
|
||||||
promotion_labels: bool = False,
|
promotion_labels: bool = False,
|
||||||
omitted_promotion_labels: bool = False,
|
omitted_promotion_labels: bool = False,
|
||||||
|
fact_only: bool = False,
|
||||||
) -> ApplyCase:
|
) -> ApplyCase:
|
||||||
contract = _contract()
|
contract = _contract(fact_only=fact_only)
|
||||||
evidence, review_resolver, reviewer = _review_evidence()
|
evidence, review_resolver, reviewer = _review_evidence()
|
||||||
review_policy = _review_policy(reviewer)
|
review_policy = _review_policy(reviewer)
|
||||||
effects = effects or (RelationEffect(RelationKind.CALL, "flow", "source", "target", ("matched",)),)
|
effects = effects or (RelationEffect(RelationKind.CALL, "flow", "source", "target", ("matched",)),)
|
||||||
@@ -283,6 +318,128 @@ def _apply(case: ApplyCase) -> tuple[SemanticRelation, ...]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _case_with_distinct_evaluation_population() -> ApplyCase:
|
||||||
|
case = _case(fact_only=True)
|
||||||
|
source_log = case.custody.current_log(case.population_digest)
|
||||||
|
evaluation_digest = canonical.content_digest("distinct-evaluation-population")
|
||||||
|
evaluation_log = provenance.empty_population_custody_log(
|
||||||
|
evaluation_digest,
|
||||||
|
case.custody_policy.reviewed_custodian_digests[0],
|
||||||
|
)
|
||||||
|
custody = MultiPopulationCustody((source_log, evaluation_log), case.custody_policy)
|
||||||
|
return replace(case, custody=custody, population_digest=evaluation_digest)
|
||||||
|
|
||||||
|
|
||||||
|
def _case_with_three_populations() -> tuple[ApplyCase, str]:
|
||||||
|
case = _case(fact_only=True)
|
||||||
|
custodian = case.custody_policy.reviewed_custodian_digests[0]
|
||||||
|
source_log = case.custody.current_log(case.population_digest)
|
||||||
|
fact_empty = provenance.empty_population_custody_log(case.population_digest, custodian)
|
||||||
|
fact_log = provenance.append_population_use(fact_empty, source_log.receipts[0], case.custody_policy)
|
||||||
|
matcher_population = canonical.content_digest("distinct-matcher-population")
|
||||||
|
matcher_empty = provenance.empty_population_custody_log(matcher_population, custodian)
|
||||||
|
matcher_request = provenance.PopulationUseRequest(
|
||||||
|
matcher_population,
|
||||||
|
"matcher-arm",
|
||||||
|
provenance.LineagePurpose.MATCHING_BEHAVIOR,
|
||||||
|
False,
|
||||||
|
case.match.matcher_behavior_digest,
|
||||||
|
matcher_empty.log_digest,
|
||||||
|
)
|
||||||
|
matcher_receipt = provenance.record_population_use(matcher_request, matcher_empty, custodian, case.custody_policy)
|
||||||
|
matcher_log = provenance.append_population_use(matcher_empty, matcher_receipt, case.custody_policy)
|
||||||
|
matcher_use = provenance.LineageUse(
|
||||||
|
provenance.LineagePurpose.MATCHING_BEHAVIOR,
|
||||||
|
matcher_population,
|
||||||
|
False,
|
||||||
|
case.match.matcher_behavior_digest,
|
||||||
|
matcher_receipt.receipt_digest,
|
||||||
|
)
|
||||||
|
matcher_lineage = _manifest("matcher-arm", case.match.matcher_behavior_digest, (matcher_use,))
|
||||||
|
provisional_match = replace(
|
||||||
|
case.match,
|
||||||
|
matcher_lineage_manifest_digest=matcher_lineage.manifest_digest,
|
||||||
|
match_digest="0" * 64,
|
||||||
|
)
|
||||||
|
match = replace(provisional_match, match_digest=derive_fact_match_digest(provisional_match))
|
||||||
|
match_reference, match_resolver = stored_artifact(fact_match_bytes(match), artifact_id="fact-match")
|
||||||
|
evaluation_digest = canonical.content_digest("third-evaluation-population")
|
||||||
|
evaluation_log = provenance.empty_population_custody_log(evaluation_digest, custodian)
|
||||||
|
custody = MultiPopulationCustody((fact_log, matcher_log, evaluation_log), case.custody_policy)
|
||||||
|
artifacts = {**case.resolver.artifacts, **match_resolver.artifacts}
|
||||||
|
changed = replace(
|
||||||
|
case,
|
||||||
|
match=match,
|
||||||
|
match_reference=match_reference,
|
||||||
|
lineages=(case.lineages[0], matcher_lineage),
|
||||||
|
custody=custody,
|
||||||
|
resolver=InMemoryContentResolver(artifacts),
|
||||||
|
population_digest=evaluation_digest,
|
||||||
|
)
|
||||||
|
return changed, matcher_population
|
||||||
|
|
||||||
|
|
||||||
|
def _stamp(case: ApplyCase) -> provenance.ProvenanceStamp:
|
||||||
|
return provenance.ProvenanceStamp(
|
||||||
|
canonical.content_digest("analyzer"),
|
||||||
|
family_contract_digest(case.contract),
|
||||||
|
canonical.content_digest("fact-set"),
|
||||||
|
canonical.content_digest("acquisition"),
|
||||||
|
canonical.content_digest("configuration"),
|
||||||
|
case.population_digest,
|
||||||
|
canonical.content_digest("change-input"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record(case: ApplyCase, relation: SemanticRelation) -> evidence.EvidenceRecord:
|
||||||
|
fields = (FieldValue("name", FieldKind.TEXT, "resource"),)
|
||||||
|
scope = evidence.AnalyzedScope(
|
||||||
|
"base",
|
||||||
|
"head",
|
||||||
|
canonical.content_digest("change"),
|
||||||
|
(evidence.SourceRegion(case.match.product_location.path, 1, 30),),
|
||||||
|
)
|
||||||
|
reasons = relation.reasons if relation.status is not RelationStatus.COMPLETE else ()
|
||||||
|
packet = evidence.EvidencePacket(
|
||||||
|
evidence.EVIDENCE_PACKET_SCHEMA,
|
||||||
|
case.contract.family_id,
|
||||||
|
family_contract_digest(case.contract),
|
||||||
|
derive_security_object_id(case.contract.security_object, fields),
|
||||||
|
case.pack.ecosystem,
|
||||||
|
scope,
|
||||||
|
(relation,),
|
||||||
|
(),
|
||||||
|
fields,
|
||||||
|
reasons,
|
||||||
|
_stamp(case),
|
||||||
|
)
|
||||||
|
outcome = NeutralOutcome.SATISFIED if relation.status is RelationStatus.COMPLETE else NeutralOutcome.INDETERMINATE
|
||||||
|
decision = evidence.Decision(
|
||||||
|
"safe" if relation.status is RelationStatus.COMPLETE else "unknown",
|
||||||
|
outcome,
|
||||||
|
evidence.EvidenceBasis.DETERMINISTIC,
|
||||||
|
scope,
|
||||||
|
reasons,
|
||||||
|
evidence.derive_packet_digest(packet),
|
||||||
|
provenance.DecisionProvenance(None, None, None, None, None, None),
|
||||||
|
)
|
||||||
|
return evidence.EvidenceRecord(packet, decision, "")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate(case: ApplyCase, record: evidence.EvidenceRecord, *, packs: tuple[FactPack, ...] | None = None) -> None:
|
||||||
|
validate_fact_relations(
|
||||||
|
record,
|
||||||
|
case.contract,
|
||||||
|
(case.pack,) if packs is None else packs,
|
||||||
|
case.population_digest,
|
||||||
|
case.lineages,
|
||||||
|
case.custody,
|
||||||
|
case.custody_policy,
|
||||||
|
case.review_policy,
|
||||||
|
case.resolver,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_complete_match_materializes_only_declared_effect_with_exact_provenance() -> None:
|
def test_complete_match_materializes_only_declared_effect_with_exact_provenance() -> None:
|
||||||
case = _case()
|
case = _case()
|
||||||
|
|
||||||
@@ -366,3 +523,267 @@ def test_omitted_current_promotion_spend_cannot_hide_behind_clean_pack_lineage()
|
|||||||
assert case.custody.current_log(case.population_digest).status is provenance.PopulationStatus.SPENT
|
assert case.custody.current_log(case.population_digest).status is provenance.PopulationStatus.SPENT
|
||||||
with pytest.raises(FactApplicationError, match="promotion.*protected_population_labels"):
|
with pytest.raises(FactApplicationError, match="promotion.*protected_population_labels"):
|
||||||
_apply(case)
|
_apply(case)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reviewed_dependency_relation_replays_from_every_named_input() -> None:
|
||||||
|
case = _case(fact_only=True)
|
||||||
|
(relation,) = _apply(case)
|
||||||
|
record = _record(case, relation)
|
||||||
|
|
||||||
|
_validate(case, record)
|
||||||
|
|
||||||
|
|
||||||
|
def test_product_only_dependency_behavior_is_refused() -> None:
|
||||||
|
case = _case(fact_only=True)
|
||||||
|
(reviewed,) = _apply(case)
|
||||||
|
product = replace(
|
||||||
|
reviewed,
|
||||||
|
provenance=EvidenceProvenance(ProvenanceKind.PRODUCT_CODE, case.match.match_digest, None, None, None),
|
||||||
|
relation_id="",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(FactRelationValidationError, match="reviewed semantic fact"):
|
||||||
|
_validate(case, _record(case, product))
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_dependency_fact_remains_named_and_indeterminate() -> None:
|
||||||
|
case = _case(fact_only=True)
|
||||||
|
location = case.match.product_location
|
||||||
|
missing = make_relation(
|
||||||
|
kind=RelationKind.CALL,
|
||||||
|
role="flow",
|
||||||
|
source=SemanticNode("source", "source", location),
|
||||||
|
target=SemanticNode("target", "target", location),
|
||||||
|
location=location,
|
||||||
|
status=RelationStatus.INCOMPLETE,
|
||||||
|
conditions=("matched",),
|
||||||
|
reasons=(MISSING_REVIEWED_DEPENDENCY,),
|
||||||
|
provenance=EvidenceProvenance(ProvenanceKind.PRODUCT_CODE, case.match.match_digest, None, None, None),
|
||||||
|
)
|
||||||
|
record = _record(case, missing)
|
||||||
|
|
||||||
|
_validate(case, record)
|
||||||
|
|
||||||
|
assert record.decision.outcome is NeutralOutcome.INDETERMINATE
|
||||||
|
assert record.decision.reasons == (MISSING_REVIEWED_DEPENDENCY,)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fact_relation_replay_refuses_undeclared_role_and_invalid_scope() -> None:
|
||||||
|
case = _case(fact_only=True)
|
||||||
|
(relation,) = _apply(case)
|
||||||
|
undeclared = replace(relation, role="undeclared", relation_id="")
|
||||||
|
with pytest.raises(FactRelationValidationError, match="undeclared semantic role"):
|
||||||
|
_validate(case, _record(case, undeclared))
|
||||||
|
|
||||||
|
record = _record(case, relation)
|
||||||
|
packet = replace(record.packet, ecosystem="javascript")
|
||||||
|
decision = replace(record.decision, packet_digest=evidence.derive_packet_digest(packet))
|
||||||
|
with pytest.raises(FactRelationValidationError, match="unsupported ecosystem scope"):
|
||||||
|
_validate(case, evidence.EvidenceRecord(packet, decision, ""))
|
||||||
|
|
||||||
|
|
||||||
|
def test_fact_relation_replay_refuses_inconsistent_relation_and_fact_identity() -> None:
|
||||||
|
case = _case(fact_only=True)
|
||||||
|
(relation,) = _apply(case)
|
||||||
|
inconsistent = replace(relation, conditions=("substituted",), relation_id="")
|
||||||
|
with pytest.raises(FactRelationValidationError, match="does not equal replayed"):
|
||||||
|
_validate(case, _record(case, inconsistent))
|
||||||
|
|
||||||
|
wrong_origin = replace(relation.provenance, semantic_fact_id="other.fact")
|
||||||
|
wrong_fact = replace(relation, provenance=wrong_origin, relation_id="")
|
||||||
|
with pytest.raises(FactRelationValidationError, match="fact identity"):
|
||||||
|
_validate(case, _record(case, wrong_fact))
|
||||||
|
|
||||||
|
|
||||||
|
def test_fact_relation_replay_refuses_substituted_resolved_match_bytes() -> None:
|
||||||
|
case = _case(fact_only=True)
|
||||||
|
(relation,) = _apply(case)
|
||||||
|
wrong_ref, wrong_resolver = stored_artifact(b'{"plausible":"but-wrong"}', artifact_id="wrong-replay-match")
|
||||||
|
wrong_origin = replace(
|
||||||
|
relation.provenance,
|
||||||
|
source_content_digest=wrong_ref.content_digest,
|
||||||
|
fact_match=wrong_ref,
|
||||||
|
)
|
||||||
|
wrong_relation = replace(relation, provenance=wrong_origin, relation_id="")
|
||||||
|
changed = replace(
|
||||||
|
case,
|
||||||
|
resolver=InMemoryContentResolver({**case.resolver.artifacts, **wrong_resolver.artifacts}),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(FactRelationValidationError, match="match artifact"):
|
||||||
|
_validate(changed, _record(changed, wrong_relation))
|
||||||
|
|
||||||
|
|
||||||
|
def test_fact_relation_replay_refuses_ambiguous_pack_and_changed_current_custody() -> None:
|
||||||
|
case = _case(fact_only=True)
|
||||||
|
(relation,) = _apply(case)
|
||||||
|
record = _record(case, relation)
|
||||||
|
with pytest.raises(FactRelationValidationError, match="pack identity is missing or ambiguous"):
|
||||||
|
_validate(case, record, packs=(case.pack, case.pack))
|
||||||
|
|
||||||
|
_append_use(
|
||||||
|
case.custody,
|
||||||
|
case.custody_policy.reviewed_custodian_digests[0],
|
||||||
|
provenance.LineagePurpose.PROMOTION,
|
||||||
|
case.pack.normalized_content_digest,
|
||||||
|
labels_accessed=True,
|
||||||
|
arm_id="other-arm",
|
||||||
|
)
|
||||||
|
with pytest.raises(FactRelationValidationError, match="promotion lineage is contaminated"):
|
||||||
|
_validate(case, record)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fact_relation_replay_refuses_omitted_current_semantic_fact_label_use() -> None:
|
||||||
|
case = _case(fact_only=True)
|
||||||
|
(relation,) = _apply(case)
|
||||||
|
record = _record(case, relation)
|
||||||
|
_append_use(
|
||||||
|
case.custody,
|
||||||
|
case.custody_policy.reviewed_custodian_digests[0],
|
||||||
|
provenance.LineagePurpose.SEMANTIC_FACT,
|
||||||
|
case.pack.normalized_content_digest,
|
||||||
|
labels_accessed=True,
|
||||||
|
arm_id="fact-arm",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert case.custody.current_log(case.population_digest).status is provenance.PopulationStatus.SPENT
|
||||||
|
with pytest.raises(FactRelationValidationError, match="semantic fact.*protected_population_labels"):
|
||||||
|
_validate(case, record)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fact_relation_replay_refuses_cross_arm_protected_semantic_fact_label_use() -> None:
|
||||||
|
case = _case(fact_only=True)
|
||||||
|
(relation,) = _apply(case)
|
||||||
|
record = _record(case, relation)
|
||||||
|
_append_use(
|
||||||
|
case.custody,
|
||||||
|
case.custody_policy.reviewed_custodian_digests[0],
|
||||||
|
provenance.LineagePurpose.SEMANTIC_FACT,
|
||||||
|
case.pack.normalized_content_digest,
|
||||||
|
labels_accessed=True,
|
||||||
|
arm_id="other-arm",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert case.custody.current_log(case.population_digest).status is provenance.PopulationStatus.SPENT
|
||||||
|
with pytest.raises(FactRelationValidationError, match="semantic fact.*protected_population_labels"):
|
||||||
|
_validate(case, record)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fact_relation_replay_refuses_cross_arm_label_use_from_lineage_source_population() -> None:
|
||||||
|
case = _case_with_distinct_evaluation_population()
|
||||||
|
(relation,) = _apply(case)
|
||||||
|
record = _record(case, relation)
|
||||||
|
source_population = canonical.content_digest("population")
|
||||||
|
_append_use(
|
||||||
|
case.custody,
|
||||||
|
case.custody_policy.reviewed_custodian_digests[0],
|
||||||
|
provenance.LineagePurpose.SEMANTIC_FACT,
|
||||||
|
case.pack.normalized_content_digest,
|
||||||
|
labels_accessed=True,
|
||||||
|
arm_id="other-arm",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert source_population != case.population_digest
|
||||||
|
assert case.custody.current_log(source_population).status is provenance.PopulationStatus.SPENT
|
||||||
|
with pytest.raises(FactRelationValidationError, match="semantic fact.*label_access_forbidden"):
|
||||||
|
_validate(case, record)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fact_relation_replay_refuses_label_use_from_other_supplied_lineage_population() -> None:
|
||||||
|
case, matcher_population = _case_with_three_populations()
|
||||||
|
(relation,) = _apply(case)
|
||||||
|
record = _record(case, relation)
|
||||||
|
_append_use(
|
||||||
|
case.custody,
|
||||||
|
case.custody_policy.reviewed_custodian_digests[0],
|
||||||
|
provenance.LineagePurpose.SEMANTIC_FACT,
|
||||||
|
case.pack.normalized_content_digest,
|
||||||
|
labels_accessed=True,
|
||||||
|
arm_id="other-arm",
|
||||||
|
population_digest=matcher_population,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matcher_population not in {
|
||||||
|
case.population_digest,
|
||||||
|
case.lineages[0].uses[0].source_population_digest,
|
||||||
|
}
|
||||||
|
assert case.custody.current_log(matcher_population).status is provenance.PopulationStatus.SPENT
|
||||||
|
with pytest.raises(FactRelationValidationError, match="semantic fact.*label_access_forbidden"):
|
||||||
|
_validate(case, record)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("purpose", "subject", "labels_accessed"),
|
||||||
|
(
|
||||||
|
(provenance.LineagePurpose.SEMANTIC_FACT, "exact-pack", False),
|
||||||
|
(provenance.LineagePurpose.MATCHING_BEHAVIOR, "exact-pack", True),
|
||||||
|
(provenance.LineagePurpose.SEMANTIC_FACT, "wrong-digest", True),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_fact_relation_replay_ignores_unrelated_cross_arm_source_receipts(
|
||||||
|
purpose: provenance.LineagePurpose,
|
||||||
|
subject: str,
|
||||||
|
labels_accessed: bool,
|
||||||
|
) -> None:
|
||||||
|
case = _case_with_distinct_evaluation_population()
|
||||||
|
(relation,) = _apply(case)
|
||||||
|
record = _record(case, relation)
|
||||||
|
influenced = case.pack.normalized_content_digest if subject == "exact-pack" else canonical.content_digest(subject)
|
||||||
|
_append_use(
|
||||||
|
case.custody,
|
||||||
|
case.custody_policy.reviewed_custodian_digests[0],
|
||||||
|
purpose,
|
||||||
|
influenced,
|
||||||
|
labels_accessed=labels_accessed,
|
||||||
|
arm_id="other-arm",
|
||||||
|
)
|
||||||
|
|
||||||
|
_validate(case, record)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("purpose", "subject", "labels_accessed"),
|
||||||
|
(
|
||||||
|
(provenance.LineagePurpose.SEMANTIC_FACT, "exact-pack", False),
|
||||||
|
(provenance.LineagePurpose.MATCHING_BEHAVIOR, "exact-pack", True),
|
||||||
|
(provenance.LineagePurpose.SEMANTIC_FACT, "wrong-digest", True),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_fact_relation_replay_ignores_unrelated_receipts_in_other_lineage_population(
|
||||||
|
purpose: provenance.LineagePurpose,
|
||||||
|
subject: str,
|
||||||
|
labels_accessed: bool,
|
||||||
|
) -> None:
|
||||||
|
case, matcher_population = _case_with_three_populations()
|
||||||
|
(relation,) = _apply(case)
|
||||||
|
record = _record(case, relation)
|
||||||
|
influenced = case.pack.normalized_content_digest if subject == "exact-pack" else canonical.content_digest(subject)
|
||||||
|
_append_use(
|
||||||
|
case.custody,
|
||||||
|
case.custody_policy.reviewed_custodian_digests[0],
|
||||||
|
purpose,
|
||||||
|
influenced,
|
||||||
|
labels_accessed=labels_accessed,
|
||||||
|
arm_id="other-arm",
|
||||||
|
population_digest=matcher_population,
|
||||||
|
)
|
||||||
|
|
||||||
|
_validate(case, record)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fact_relation_replay_refuses_omitted_current_semantic_fact_use_without_labels() -> None:
|
||||||
|
case = _case(fact_only=True)
|
||||||
|
(relation,) = _apply(case)
|
||||||
|
record = _record(case, relation)
|
||||||
|
_append_use(
|
||||||
|
case.custody,
|
||||||
|
case.custody_policy.reviewed_custodian_digests[0],
|
||||||
|
provenance.LineagePurpose.SEMANTIC_FACT,
|
||||||
|
case.pack.normalized_content_digest,
|
||||||
|
labels_accessed=False,
|
||||||
|
arm_id="fact-arm",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert case.custody.current_log(case.population_digest).status is provenance.PopulationStatus.AVAILABLE
|
||||||
|
with pytest.raises(FactRelationValidationError, match="semantic fact lineage omitted current use"):
|
||||||
|
_validate(case, record)
|
||||||
|
|||||||
Reference in New Issue
Block a user