This commit is contained in:
1 parent
4db3f45164
commit
d25c04cdd2
175 files changed
+56243
-15
No files matched your search
@@ -0,0 +1,695 @@
|
||||
"""Deterministic proposal and evidence validation rules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import unicodedata
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from gewu.domain.contracts import (
|
||||
AutoAcceptPolicy,
|
||||
ErrorCode,
|
||||
EvidenceReference,
|
||||
ExtractionBlock,
|
||||
ExtractionRecord,
|
||||
Finding,
|
||||
KnowledgeNode,
|
||||
Locator,
|
||||
ProjectRecord,
|
||||
ProjectStatus,
|
||||
ProposalRevision,
|
||||
SourceRevisionRecord,
|
||||
SupportStatus,
|
||||
TypedEdge,
|
||||
)
|
||||
|
||||
|
||||
def canonical_json(value: object) -> bytes:
|
||||
"""Serialize contract data deterministically for digests and replay checks."""
|
||||
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def assertion_digest(text: str) -> str:
|
||||
normalized = unicodedata.normalize("NFC", text.replace("\r\n", "\n").replace("\r", "\n"))
|
||||
return hashlib.sha256(canonical_json({"assertion": normalized})).hexdigest()
|
||||
|
||||
|
||||
def evidence_digest(evidence: EvidenceReference) -> str:
|
||||
"""Hash a typed evidence reference using its public JSON representation."""
|
||||
|
||||
return hashlib.sha256(canonical_json(evidence.model_dump(mode="json"))).hexdigest()
|
||||
|
||||
|
||||
def extraction_digest(extraction: ExtractionRecord) -> str:
|
||||
value = extraction.model_dump(mode="json", exclude={"extraction_id"})
|
||||
return hashlib.sha256(canonical_json(value)).hexdigest()
|
||||
|
||||
|
||||
def proposal_payload_digest(proposal: ProposalRevision) -> str:
|
||||
operations = [item.model_dump(mode="json") for item in proposal.operations]
|
||||
return hashlib.sha256(canonical_json(operations)).hexdigest()
|
||||
|
||||
|
||||
def edge_assertion_digest(edge: TypedEdge) -> str:
|
||||
"""Bind edge evidence to the relationship, conditions and rationale."""
|
||||
|
||||
value = edge.model_dump(mode="json", exclude={"edge_id", "evidence"})
|
||||
return hashlib.sha256(canonical_json(value)).hexdigest()
|
||||
|
||||
|
||||
def _locator_matches_block(locator: Locator, block: ExtractionBlock) -> bool:
|
||||
if locator.kind == "pdf_page_span":
|
||||
return (
|
||||
locator.page == block.page and block.time_start_ms is None and block.time_end_ms is None
|
||||
)
|
||||
if locator.kind == "transcript_timestamp_span":
|
||||
return (
|
||||
locator.page is None
|
||||
and locator.time_start_ms == block.time_start_ms
|
||||
and locator.time_end_ms == block.time_end_ms
|
||||
)
|
||||
return (
|
||||
locator.page is None
|
||||
and locator.time_start_ms is None
|
||||
and locator.time_end_ms is None
|
||||
and block.page is None
|
||||
and block.time_start_ms is None
|
||||
and block.time_end_ms is None
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ValidationReport:
|
||||
status: str
|
||||
eligible_for_auto_accept: bool
|
||||
findings: tuple[Finding, ...]
|
||||
affected_policy_ids: tuple[str, ...]
|
||||
|
||||
|
||||
def _finding(code: str, message: str, path: str | None = None) -> Finding:
|
||||
return Finding(code=code, message=message, path=path)
|
||||
|
||||
|
||||
def _entity_names(node: KnowledgeNode) -> set[str]:
|
||||
return {
|
||||
unicodedata.normalize("NFC", " ".join(value.split())).casefold()
|
||||
for value in (node.title, *node.aliases)
|
||||
if value.strip()
|
||||
}
|
||||
|
||||
|
||||
def _entity_visible(
|
||||
candidate: KnowledgeNode,
|
||||
existing: KnowledgeNode,
|
||||
readable_project_ids: frozenset[str],
|
||||
) -> bool:
|
||||
return (
|
||||
existing.project_id == candidate.project_id or existing.project_id in readable_project_ids
|
||||
)
|
||||
|
||||
|
||||
def validate_proposal(
|
||||
proposal: ProposalRevision,
|
||||
*,
|
||||
projects: dict[str, ProjectRecord],
|
||||
policies: dict[str, AutoAcceptPolicy],
|
||||
sources: dict[str, SourceRevisionRecord],
|
||||
extractions: dict[str, ExtractionRecord],
|
||||
existing_nodes: dict[str, KnowledgeNode],
|
||||
current_base_commit: str,
|
||||
readable_project_ids: frozenset[str] = frozenset(),
|
||||
) -> ValidationReport:
|
||||
"""Validate evidence, scope, relationships, policy and replay bindings."""
|
||||
|
||||
findings: list[Finding] = []
|
||||
affected_policy_ids: set[str] = set()
|
||||
eligibility_checks: list[tuple[tuple[str, ...], str, str]] = []
|
||||
scoped_projects = set(projects) if proposal.scope.all_projects else set(proposal.scope.projects)
|
||||
if proposal.scope.include_shared:
|
||||
scoped_projects.add("shared")
|
||||
comparison_project_ids = readable_project_ids & frozenset(scoped_projects)
|
||||
if (
|
||||
not proposal.scope.all_projects
|
||||
and proposal.initiating_project_id != "shared"
|
||||
and proposal.initiating_project_id not in proposal.scope.projects
|
||||
):
|
||||
findings.append(
|
||||
_finding(
|
||||
"OUT_OF_SCOPE", "Initiating project is outside explicit scope", "scope.projects"
|
||||
)
|
||||
)
|
||||
known_sources = dict(sources)
|
||||
proposed_nodes = {
|
||||
operation.node.node_id: operation.node
|
||||
for operation in proposal.operations
|
||||
if operation.node is not None
|
||||
}
|
||||
known_nodes = {**existing_nodes, **proposed_nodes}
|
||||
entity_nodes_seen: list[KnowledgeNode] = []
|
||||
|
||||
if proposal.base_commit != current_base_commit:
|
||||
findings.append(_finding("BASE_CONFLICT", "Canonical base revision has changed"))
|
||||
if proposal_payload_digest(proposal) != proposal.payload_digest:
|
||||
findings.append(_finding("PAYLOAD_DIGEST_MISMATCH", "Proposal payload digest is incorrect"))
|
||||
|
||||
for index, operation in enumerate(proposal.operations):
|
||||
path = f"operations[{index}]"
|
||||
if operation.source is not None:
|
||||
source = operation.source
|
||||
previous = known_sources.get(source.revision_id)
|
||||
if previous and previous != source:
|
||||
findings.append(
|
||||
_finding(
|
||||
"SOURCE_REVISION_CONFLICT", "Source revision ID is already bound", path
|
||||
)
|
||||
)
|
||||
known_sources[source.revision_id] = source
|
||||
source_domains = {"shared", *source.ingested_for}
|
||||
for project_id in source.ingested_for:
|
||||
if project_id != "shared" and project_id not in projects:
|
||||
findings.append(
|
||||
_finding(
|
||||
"PROJECT_NOT_REGISTERED",
|
||||
"Source relevance names an unregistered project",
|
||||
f"{path}.source.ingested_for",
|
||||
)
|
||||
)
|
||||
if (
|
||||
project_id != "shared"
|
||||
and project_id not in proposal.scope.projects
|
||||
and not proposal.scope.all_projects
|
||||
):
|
||||
findings.append(
|
||||
_finding(
|
||||
"OUT_OF_SCOPE",
|
||||
"Source relevance names a project outside explicit scope",
|
||||
f"{path}.source.ingested_for",
|
||||
)
|
||||
)
|
||||
affected_policy_ids.update(source_domains)
|
||||
eligibility_checks.append((tuple(sorted(source_domains)), "source", "register_source"))
|
||||
|
||||
if operation.node is not None:
|
||||
node = operation.node
|
||||
eligibility_checks.append(((node.project_id,), "node", node.node_type.value))
|
||||
if node.node_id in existing_nodes:
|
||||
findings.append(
|
||||
_finding(
|
||||
"NODE_ID_EXISTS",
|
||||
"Node IDs are stable and cannot be reused",
|
||||
f"{path}.node.node_id",
|
||||
)
|
||||
)
|
||||
if node.node_type.value == "entity":
|
||||
possible_duplicates = [
|
||||
other
|
||||
for other in (*existing_nodes.values(), *entity_nodes_seen)
|
||||
if other.node_id != node.node_id
|
||||
and other.node_type.value == "entity"
|
||||
and _entity_visible(node, other, comparison_project_ids)
|
||||
and _entity_names(node) & _entity_names(other)
|
||||
]
|
||||
if possible_duplicates:
|
||||
findings.append(
|
||||
_finding(
|
||||
"POSSIBLE_DUPLICATE_ENTITY",
|
||||
"Entity name or alias matches an in-scope entity; review provenance "
|
||||
"before linking or merging",
|
||||
f"{path}.node",
|
||||
)
|
||||
)
|
||||
entity_nodes_seen.append(node)
|
||||
if node.project_id != "shared" and node.project_id not in projects:
|
||||
findings.append(
|
||||
_finding(
|
||||
"PROJECT_NOT_REGISTERED",
|
||||
"Node project is not registered",
|
||||
f"{path}.node.project_id",
|
||||
)
|
||||
)
|
||||
affected_policy_ids.add(node.project_id)
|
||||
if node.agent_id != proposal.proposer_id:
|
||||
findings.append(
|
||||
_finding(
|
||||
"AGENT_ATTRIBUTION_MISMATCH",
|
||||
"Node attribution must match the authenticated proposal author",
|
||||
f"{path}.node.agent_id",
|
||||
)
|
||||
)
|
||||
claim_items = (
|
||||
(group_name, claim_index, claim)
|
||||
for group_name, claims in (
|
||||
("claims", node.claims),
|
||||
("assumptions", node.assumptions),
|
||||
("limitations", node.limitations),
|
||||
)
|
||||
for claim_index, claim in enumerate(claims)
|
||||
)
|
||||
for group_name, claim_index, claim in claim_items:
|
||||
claim_path = f"{path}.node.{group_name}[{claim_index}]"
|
||||
if claim.assessment.assessor_id != proposal.proposer_id:
|
||||
findings.append(
|
||||
_finding(
|
||||
"ASSESSOR_ATTRIBUTION_MISMATCH",
|
||||
"Support assessment must be attributed to the proposal author",
|
||||
f"{claim_path}.assessment.assessor_id",
|
||||
)
|
||||
)
|
||||
expected_assertion_digest = assertion_digest(claim.text)
|
||||
assessments = {claim.assessment.assertion_digest}
|
||||
if assessments != {expected_assertion_digest}:
|
||||
findings.append(
|
||||
_finding(
|
||||
"ASSESSMENT_STALE",
|
||||
"Support assessment does not bind the current claim text",
|
||||
claim_path,
|
||||
)
|
||||
)
|
||||
expected_evidence_digests = {
|
||||
evidence_digest(evidence) for evidence in claim.evidence
|
||||
}
|
||||
if set(claim.assessment.evidence_digests) != expected_evidence_digests:
|
||||
findings.append(
|
||||
_finding(
|
||||
"ASSESSMENT_EVIDENCE_MISMATCH",
|
||||
"Support assessment does not bind the current evidence",
|
||||
claim_path,
|
||||
)
|
||||
)
|
||||
if claim.assessment.status != SupportStatus.SUPPORTED:
|
||||
findings.append(
|
||||
_finding(
|
||||
"SUPPORT_UNAVAILABLE",
|
||||
"Claim lacks a supported semantic assessment",
|
||||
claim_path,
|
||||
)
|
||||
)
|
||||
for evidence_index, evidence in enumerate(claim.evidence):
|
||||
evidence_path = f"{claim_path}.evidence[{evidence_index}]"
|
||||
expected_assertion_path = f"{group_name}[{claim_index}].text"
|
||||
if evidence.assertion_path != expected_assertion_path:
|
||||
findings.append(
|
||||
_finding(
|
||||
"EVIDENCE_ASSERTION_PATH_MISMATCH",
|
||||
"Evidence assertion path does not match its typed claim group",
|
||||
evidence_path,
|
||||
)
|
||||
)
|
||||
if evidence.assertion_digest != expected_assertion_digest:
|
||||
findings.append(
|
||||
_finding(
|
||||
"EVIDENCE_ASSERTION_MISMATCH",
|
||||
"Evidence is not bound to the current claim text",
|
||||
evidence_path,
|
||||
)
|
||||
)
|
||||
source_record = known_sources.get(evidence.source_revision_id)
|
||||
if source_record is None:
|
||||
findings.append(
|
||||
_finding(
|
||||
"SOURCE_REVISION_MISSING",
|
||||
"Evidence source revision is unavailable",
|
||||
evidence_path,
|
||||
)
|
||||
)
|
||||
extraction = extractions.get(evidence.extraction_id)
|
||||
if (
|
||||
extraction is None
|
||||
or extraction.source_revision_id != evidence.source_revision_id
|
||||
):
|
||||
findings.append(
|
||||
_finding(
|
||||
"EXTRACTION_MISMATCH",
|
||||
"Evidence extraction does not belong to its source revision",
|
||||
evidence_path,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if extraction_digest(extraction) != extraction.extraction_id:
|
||||
findings.append(
|
||||
_finding(
|
||||
"EXTRACTION_HASH_MISMATCH",
|
||||
"Stored extraction does not match its immutable identity",
|
||||
evidence_path,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if source_record and source_record.archive_status != "archived":
|
||||
findings.append(
|
||||
_finding(
|
||||
"SOURCE_NOT_ARCHIVED",
|
||||
"Reference-only sources cannot support accepted factual claims",
|
||||
evidence_path,
|
||||
)
|
||||
)
|
||||
block = next(
|
||||
(
|
||||
item
|
||||
for item in extraction.blocks
|
||||
if item.block_id == evidence.locator.block_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if block is None:
|
||||
findings.append(
|
||||
_finding(
|
||||
"EVIDENCE_LOCATION_MISMATCH",
|
||||
"Evidence block does not exist",
|
||||
evidence_path,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if not _locator_matches_block(evidence.locator, block):
|
||||
findings.append(
|
||||
_finding(
|
||||
"EVIDENCE_LOCATION_MISMATCH",
|
||||
"Evidence locator metadata does not match its source block",
|
||||
evidence_path,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if evidence.locator.end > len(block.normalized_text):
|
||||
findings.append(
|
||||
_finding(
|
||||
"EVIDENCE_LOCATION_MISMATCH",
|
||||
"Evidence span extends beyond its source block",
|
||||
evidence_path,
|
||||
)
|
||||
)
|
||||
continue
|
||||
located_text = block.normalized_text[
|
||||
evidence.locator.start : evidence.locator.end
|
||||
]
|
||||
normalized_quote = unicodedata.normalize(
|
||||
"NFC", evidence.quote.replace("\r\n", "\n").replace("\r", "\n")
|
||||
)
|
||||
location_map = block.normalization_map[
|
||||
evidence.locator.start : evidence.locator.end
|
||||
]
|
||||
original_start = min((span[0] for span in location_map), default=-1)
|
||||
original_end = max((span[1] for span in location_map), default=-1)
|
||||
original_quote = (
|
||||
block.original_text[original_start:original_end]
|
||||
if original_start >= 0
|
||||
else ""
|
||||
)
|
||||
normalized_original_quote = unicodedata.normalize(
|
||||
"NFC", original_quote.replace("\r\n", "\n").replace("\r", "\n")
|
||||
)
|
||||
if (
|
||||
located_text != normalized_quote
|
||||
or normalized_original_quote != normalized_quote
|
||||
):
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.EVIDENCE_MISMATCH.value,
|
||||
"Quote does not match text at the stated location",
|
||||
evidence_path,
|
||||
)
|
||||
)
|
||||
|
||||
if operation.edge is not None:
|
||||
edge = operation.edge
|
||||
expected_edge_digest = edge_assertion_digest(edge)
|
||||
for evidence_index, evidence in enumerate(edge.evidence):
|
||||
evidence_path = f"{path}.edge.evidence[{evidence_index}]"
|
||||
if evidence.assertion_digest != expected_edge_digest:
|
||||
findings.append(
|
||||
_finding(
|
||||
"EVIDENCE_ASSERTION_MISMATCH",
|
||||
"Evidence is not bound to the current relationship",
|
||||
evidence_path,
|
||||
)
|
||||
)
|
||||
source_record = known_sources.get(evidence.source_revision_id)
|
||||
extraction = extractions.get(evidence.extraction_id)
|
||||
if source_record is None:
|
||||
findings.append(
|
||||
_finding(
|
||||
"SOURCE_REVISION_MISSING",
|
||||
"Evidence source revision is unavailable",
|
||||
evidence_path,
|
||||
)
|
||||
)
|
||||
if (
|
||||
extraction is None
|
||||
or extraction.source_revision_id != evidence.source_revision_id
|
||||
):
|
||||
findings.append(
|
||||
_finding(
|
||||
"EXTRACTION_MISMATCH",
|
||||
"Evidence extraction does not belong to its source revision",
|
||||
evidence_path,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if extraction_digest(extraction) != extraction.extraction_id:
|
||||
findings.append(
|
||||
_finding(
|
||||
"EXTRACTION_HASH_MISMATCH",
|
||||
"Stored extraction does not match its immutable identity",
|
||||
evidence_path,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if source_record and source_record.archive_status != "archived":
|
||||
findings.append(
|
||||
_finding(
|
||||
"SOURCE_NOT_ARCHIVED",
|
||||
"Reference-only sources cannot support accepted relationships",
|
||||
evidence_path,
|
||||
)
|
||||
)
|
||||
block = next(
|
||||
(
|
||||
item
|
||||
for item in extraction.blocks
|
||||
if item.block_id == evidence.locator.block_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if block is None or not _locator_matches_block(evidence.locator, block):
|
||||
findings.append(
|
||||
_finding(
|
||||
"EVIDENCE_LOCATION_MISMATCH",
|
||||
"Evidence block or locator metadata does not match",
|
||||
evidence_path,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if evidence.locator.end > len(block.normalized_text):
|
||||
findings.append(
|
||||
_finding(
|
||||
"EVIDENCE_LOCATION_MISMATCH",
|
||||
"Evidence span extends beyond its source block",
|
||||
evidence_path,
|
||||
)
|
||||
)
|
||||
continue
|
||||
actual_quote = block.normalized_text[evidence.locator.start : evidence.locator.end]
|
||||
if actual_quote != unicodedata.normalize(
|
||||
"NFC", evidence.quote.replace("\\r\\n", "\\n").replace("\\r", "\\n")
|
||||
):
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.EVIDENCE_MISMATCH.value,
|
||||
"Quote does not match text at the stated location",
|
||||
evidence_path,
|
||||
)
|
||||
)
|
||||
source_node = known_nodes.get(edge.source.id) if edge.source.kind == "node" else None
|
||||
target_node = known_nodes.get(edge.target.id) if edge.target.kind == "node" else None
|
||||
edge_domains: set[str] = set()
|
||||
for endpoint, node_endpoint, label in (
|
||||
(edge.source, source_node, "source"),
|
||||
(edge.target, target_node, "target"),
|
||||
):
|
||||
if endpoint.kind == "node":
|
||||
if node_endpoint is None:
|
||||
findings.append(
|
||||
_finding(
|
||||
"EDGE_TARGET_MISSING",
|
||||
f"Edge {label} node {endpoint.id} is unavailable",
|
||||
f"{path}.edge.{label}.id",
|
||||
)
|
||||
)
|
||||
else:
|
||||
affected_policy_ids.add(node_endpoint.project_id)
|
||||
edge_domains.add(node_endpoint.project_id)
|
||||
elif endpoint.kind == "source":
|
||||
if endpoint.id not in known_sources and endpoint.id not in {
|
||||
source.revision_id
|
||||
for source in (
|
||||
item.source for item in proposal.operations if item.source is not None
|
||||
)
|
||||
}:
|
||||
findings.append(
|
||||
_finding(
|
||||
"EDGE_SOURCE_MISSING",
|
||||
f"Edge source revision {endpoint.id} is unavailable",
|
||||
f"{path}.edge.{label}.id",
|
||||
)
|
||||
)
|
||||
affected_policy_ids.add("shared")
|
||||
edge_domains.add("shared")
|
||||
elif endpoint.id not in projects:
|
||||
findings.append(
|
||||
_finding(
|
||||
"EDGE_PROJECT_MISSING",
|
||||
f"Edge project {endpoint.id} is not registered",
|
||||
f"{path}.edge.{label}.id",
|
||||
)
|
||||
)
|
||||
else:
|
||||
affected_policy_ids.add(endpoint.id)
|
||||
edge_domains.add(endpoint.id)
|
||||
if edge_domains:
|
||||
eligibility_checks.append((tuple(sorted(edge_domains)), "edge", edge.edge_type))
|
||||
if edge.edge_type in {"solves", "addresses"} and source_node and target_node:
|
||||
if (
|
||||
source_node.node_type.value != "technique"
|
||||
or target_node.node_type.value != "problem"
|
||||
):
|
||||
findings.append(
|
||||
_finding(
|
||||
"EDGE_TYPE_MISMATCH",
|
||||
f"{edge.edge_type} must connect a technique to a problem",
|
||||
f"{path}.edge",
|
||||
)
|
||||
)
|
||||
|
||||
if operation.target_id is not None:
|
||||
target = existing_nodes.get(operation.target_id)
|
||||
if target is None:
|
||||
findings.append(
|
||||
_finding(
|
||||
"NODE_NOT_FOUND", "Staleness target does not exist", f"{path}.target_id"
|
||||
)
|
||||
)
|
||||
else:
|
||||
affected_policy_ids.add(target.project_id)
|
||||
eligibility_checks.append(((target.project_id,), "stale", "flag_stale"))
|
||||
|
||||
policy_by_domain: dict[str, AutoAcceptPolicy] = {}
|
||||
for project_id in sorted(affected_policy_ids):
|
||||
project = projects.get(project_id)
|
||||
if project and project.status != ProjectStatus.ACTIVE:
|
||||
findings.append(
|
||||
_finding(
|
||||
"PROJECT_NOT_ACTIVE",
|
||||
f"Project {project_id} is {project.status.value} and cannot accept changes",
|
||||
)
|
||||
)
|
||||
policy_id = project.autonomy_policy_id if project else project_id
|
||||
policy = policies.get(policy_id)
|
||||
if policy is None or policy.project_id != project_id:
|
||||
findings.append(
|
||||
_finding("POLICY_MISSING", f"No applicable policy is registered for {project_id}")
|
||||
)
|
||||
continue
|
||||
if not policy.require_supported_assessment:
|
||||
findings.append(
|
||||
_finding(
|
||||
"POLICY_WEAKENING",
|
||||
"Policy cannot waive required claim support assessments",
|
||||
f"policies.{policy_id}.require_supported_assessment",
|
||||
)
|
||||
)
|
||||
policy_by_domain[project_id] = policy
|
||||
if project_id == "shared" and not proposal.scope.include_shared:
|
||||
findings.append(
|
||||
_finding(
|
||||
"OUT_OF_SCOPE", "Proposal changes shared outside its explicit scope", "scope"
|
||||
)
|
||||
)
|
||||
elif project_id != "shared" and project_id not in proposal.scope.projects:
|
||||
findings.append(
|
||||
_finding(
|
||||
"OUT_OF_SCOPE",
|
||||
"Proposal changes a project outside its explicit scope",
|
||||
"scope.projects",
|
||||
)
|
||||
)
|
||||
|
||||
automatic = bool(policy_by_domain) and len(policy_by_domain) == len(affected_policy_ids)
|
||||
for domains, change_kind, change_type in eligibility_checks:
|
||||
applicable = [policy_by_domain.get(project_id) for project_id in domains]
|
||||
if any(policy is None for policy in applicable):
|
||||
automatic = False
|
||||
elif change_kind == "source":
|
||||
automatic = automatic and all(
|
||||
bool(policy and policy.automatic_source_registration) for policy in applicable
|
||||
)
|
||||
elif change_kind == "node":
|
||||
automatic = automatic and all(
|
||||
bool(policy and change_type in policy.automatic_node_types) for policy in applicable
|
||||
)
|
||||
elif change_kind == "edge":
|
||||
automatic = automatic and all(
|
||||
bool(policy and change_type in policy.automatic_edge_types) for policy in applicable
|
||||
)
|
||||
else:
|
||||
automatic = False
|
||||
if not automatic and not findings:
|
||||
findings.append(
|
||||
_finding(
|
||||
"AUTO_ACCEPT_UNSPECIFIED", "Applicable policy requires an exceptional decision"
|
||||
)
|
||||
)
|
||||
|
||||
blocking_codes = {"BASE_CONFLICT", "PAYLOAD_DIGEST_MISMATCH"}
|
||||
invalid_codes = {
|
||||
"SOURCE_REVISION_CONFLICT",
|
||||
"PROJECT_NOT_REGISTERED",
|
||||
"NODE_ID_EXISTS",
|
||||
"ASSESSMENT_STALE",
|
||||
"ASSESSMENT_EVIDENCE_MISMATCH",
|
||||
"SUPPORT_UNAVAILABLE",
|
||||
"EVIDENCE_ASSERTION_MISMATCH",
|
||||
"SOURCE_REVISION_MISSING",
|
||||
"EXTRACTION_MISMATCH",
|
||||
"EXTRACTION_HASH_MISMATCH",
|
||||
"SOURCE_NOT_ARCHIVED",
|
||||
"EVIDENCE_LOCATION_MISMATCH",
|
||||
ErrorCode.EVIDENCE_MISMATCH.value,
|
||||
"EDGE_TARGET_MISSING",
|
||||
"EDGE_SOURCE_MISSING",
|
||||
"EDGE_PROJECT_MISSING",
|
||||
"EDGE_TYPE_MISMATCH",
|
||||
"NODE_NOT_FOUND",
|
||||
"POLICY_WEAKENING",
|
||||
"OUT_OF_SCOPE",
|
||||
}
|
||||
codes = {item.code for item in findings}
|
||||
if codes & blocking_codes:
|
||||
status = "conflict"
|
||||
elif codes & invalid_codes:
|
||||
status = "invalid"
|
||||
elif findings:
|
||||
status = "needs-review"
|
||||
else:
|
||||
status = "eligible"
|
||||
return ValidationReport(
|
||||
status=status,
|
||||
eligible_for_auto_accept=status == "eligible" and automatic,
|
||||
findings=tuple(findings),
|
||||
affected_policy_ids=tuple(sorted(policy.policy_id for policy in policy_by_domain.values())),
|
||||
)
|
||||
|
||||
|
||||
def unique_findings(findings: Iterable[Finding]) -> tuple[Finding, ...]:
|
||||
"""Stable deduplication for findings shared across bundle members."""
|
||||
|
||||
result: dict[tuple[str, str, str | None], Finding] = {}
|
||||
for item in findings:
|
||||
result[(item.code, item.message, item.path)] = item
|
||||
return tuple(result.values())
|
||||
Reference in new issue
Block a user