2026-10-14 19:21:35 +08:00
|
|
|
"""Deterministic proposal and evidence validation rules."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import hashlib
|
|
|
|
|
import json
|
2026-10-16 20:02:29 +08:00
|
|
|
import re
|
2026-10-14 19:21:35 +08:00
|
|
|
import unicodedata
|
|
|
|
|
from collections.abc import Iterable
|
2026-10-15 10:53:30 +08:00
|
|
|
from copy import deepcopy
|
2026-10-14 19:21:35 +08:00
|
|
|
from dataclasses import dataclass
|
2026-10-15 10:53:30 +08:00
|
|
|
from typing import Any
|
2026-10-14 19:21:35 +08:00
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 20:02:29 +08:00
|
|
|
def _numeric_tokens(text: str) -> set[str]:
|
|
|
|
|
"""Return normalized numeric literals for a conservative evidence check."""
|
|
|
|
|
matches = re.findall(r"(?<!\w)\d+(?:,\d{3})*(?:\.\d+)?", text)
|
|
|
|
|
return {match.replace(",", "") for match in matches}
|
|
|
|
|
|
|
|
|
|
|
2026-10-14 19:21:35 +08:00
|
|
|
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]
|
2026-10-16 20:02:29 +08:00
|
|
|
|
|
|
|
|
def omit_empty_online_evidence(value: Any) -> None:
|
|
|
|
|
if isinstance(value, dict):
|
|
|
|
|
if value.get("online_evidence") == []:
|
|
|
|
|
value.pop("online_evidence")
|
|
|
|
|
for child in value.values():
|
|
|
|
|
omit_empty_online_evidence(child)
|
|
|
|
|
elif isinstance(value, list):
|
|
|
|
|
for child in value:
|
|
|
|
|
omit_empty_online_evidence(child)
|
|
|
|
|
|
|
|
|
|
omit_empty_online_evidence(operations)
|
2026-10-14 19:21:35 +08:00
|
|
|
return hashlib.sha256(canonical_json(operations)).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
2026-10-15 10:53:30 +08:00
|
|
|
def prepare_proposal_digests(draft: dict[str, Any]) -> ProposalRevision:
|
|
|
|
|
"""Validate a proposal draft, fill its dependent evidence digests, and pin its payload."""
|
|
|
|
|
|
|
|
|
|
value = deepcopy(draft)
|
|
|
|
|
operations = value.get("operations")
|
|
|
|
|
if not isinstance(operations, list) or not operations:
|
|
|
|
|
raise ValueError("Proposal must contain a non-empty operations array")
|
|
|
|
|
|
|
|
|
|
for operation in operations:
|
|
|
|
|
if not isinstance(operation, dict):
|
|
|
|
|
raise ValueError("Proposal operations must be objects")
|
|
|
|
|
node = operation.get("node")
|
|
|
|
|
if isinstance(node, dict):
|
|
|
|
|
for group in ("claims", "assumptions", "limitations"):
|
|
|
|
|
claims = node.get(group, [])
|
|
|
|
|
if not isinstance(claims, list):
|
|
|
|
|
raise ValueError(f"Node {group} must be an array")
|
|
|
|
|
for index, claim in enumerate(claims):
|
|
|
|
|
if not isinstance(claim, dict) or not isinstance(claim.get("text"), str):
|
|
|
|
|
raise ValueError(f"Node {group}[{index}] must contain claim text")
|
|
|
|
|
digest = assertion_digest(claim["text"])
|
|
|
|
|
evidence = claim.get("evidence")
|
|
|
|
|
if not isinstance(evidence, list) or not evidence:
|
|
|
|
|
raise ValueError(f"Node {group}[{index}] must contain evidence")
|
|
|
|
|
path = f"{group}[{index}].text"
|
|
|
|
|
evidence_digests = []
|
|
|
|
|
for item in evidence:
|
|
|
|
|
if not isinstance(item, dict):
|
|
|
|
|
raise ValueError("Evidence references must be objects")
|
|
|
|
|
item["assertion_path"] = path
|
|
|
|
|
item["assertion_digest"] = digest
|
|
|
|
|
parsed_evidence = EvidenceReference.model_validate(item, strict=True)
|
|
|
|
|
item.clear()
|
|
|
|
|
item.update(parsed_evidence.model_dump(mode="json"))
|
|
|
|
|
evidence_digests.append(evidence_digest(parsed_evidence))
|
|
|
|
|
assessment = claim.get("assessment")
|
|
|
|
|
if not isinstance(assessment, dict):
|
|
|
|
|
raise ValueError(f"Node {group}[{index}] must contain an assessment")
|
|
|
|
|
assessment["assertion_digest"] = digest
|
|
|
|
|
assessment["evidence_digests"] = evidence_digests
|
|
|
|
|
|
|
|
|
|
edge = operation.get("edge")
|
|
|
|
|
if isinstance(edge, dict):
|
|
|
|
|
edge_evidence = edge.get("evidence", [])
|
|
|
|
|
if not isinstance(edge_evidence, list):
|
|
|
|
|
raise ValueError("Edge evidence must be an array")
|
|
|
|
|
for item in edge_evidence:
|
|
|
|
|
if not isinstance(item, dict):
|
|
|
|
|
raise ValueError("Evidence references must be objects")
|
|
|
|
|
item["assertion_path"] = "edge"
|
|
|
|
|
item["assertion_digest"] = "0" * 64
|
2026-10-16 20:02:29 +08:00
|
|
|
# The draft originated as JSON, where arrays represent tuple fields.
|
|
|
|
|
# Revalidate through the JSON path so strict mode accepts the typed
|
|
|
|
|
# edge evidence array without weakening Python-object validation.
|
|
|
|
|
parsed_edge = TypedEdge.model_validate_json(
|
|
|
|
|
json.dumps(edge, ensure_ascii=False, allow_nan=False), strict=True
|
|
|
|
|
)
|
2026-10-15 10:53:30 +08:00
|
|
|
digest = edge_assertion_digest(parsed_edge)
|
|
|
|
|
for item in edge_evidence:
|
|
|
|
|
item["assertion_digest"] = digest
|
|
|
|
|
parsed_evidence = EvidenceReference.model_validate(item, strict=True)
|
|
|
|
|
item.clear()
|
|
|
|
|
item.update(parsed_evidence.model_dump(mode="json"))
|
|
|
|
|
edge["evidence"] = edge_evidence
|
|
|
|
|
|
|
|
|
|
value["payload_digest"] = "0" * 64
|
|
|
|
|
provisional = ProposalRevision.model_validate_json(
|
|
|
|
|
json.dumps(value, ensure_ascii=False, allow_nan=False), strict=True
|
|
|
|
|
)
|
|
|
|
|
return provisional.model_copy(update={"payload_digest": proposal_payload_digest(provisional)})
|
|
|
|
|
|
|
|
|
|
|
2026-10-14 19:21:35 +08:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 20:02:29 +08:00
|
|
|
def _validate_node_enrichment(
|
|
|
|
|
previous: KnowledgeNode,
|
|
|
|
|
updated: KnowledgeNode,
|
|
|
|
|
proposer_id: str,
|
|
|
|
|
path: str,
|
|
|
|
|
) -> tuple[Finding, ...]:
|
|
|
|
|
"""Require an update to preserve accepted knowledge and add only assessed material."""
|
|
|
|
|
findings: list[Finding] = []
|
|
|
|
|
for field in ("node_id", "project_id", "node_type", "created", "lifecycle"):
|
|
|
|
|
if getattr(previous, field) != getattr(updated, field):
|
|
|
|
|
findings.append(
|
|
|
|
|
_finding("NODE_ENRICHMENT_IDENTITY_CHANGED", f"Enrichment cannot change {field}", path)
|
|
|
|
|
)
|
|
|
|
|
if updated.updated <= previous.updated:
|
|
|
|
|
findings.append(
|
|
|
|
|
_finding("NODE_ENRICHMENT_TIMESTAMP_INVALID", "Enrichment must advance updated", path)
|
|
|
|
|
)
|
|
|
|
|
if updated.summary != previous.summary:
|
|
|
|
|
findings.append(
|
|
|
|
|
_finding(
|
|
|
|
|
"NODE_ENRICHMENT_SUMMARY_CHANGED",
|
|
|
|
|
"Enrichment cannot replace the summary; add supported claims instead",
|
|
|
|
|
f"{path}.summary",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
for field in ("claims", "assumptions", "limitations"):
|
|
|
|
|
old_items = {claim.claim_id: claim for claim in getattr(previous, field)}
|
|
|
|
|
new_items = {claim.claim_id: claim for claim in getattr(updated, field)}
|
|
|
|
|
if not old_items.keys() <= new_items.keys():
|
|
|
|
|
findings.append(
|
|
|
|
|
_finding("NODE_ENRICHMENT_REMOVED_CLAIM", "Enrichment cannot remove accepted claims", f"{path}.{field}")
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
for claim_id, old_claim in old_items.items():
|
|
|
|
|
new_claim = new_items[claim_id]
|
|
|
|
|
claim_path = f"{path}.{field}.{claim_id}"
|
|
|
|
|
if (
|
|
|
|
|
old_claim.text != new_claim.text
|
|
|
|
|
or old_claim.evidence_type != new_claim.evidence_type
|
|
|
|
|
):
|
|
|
|
|
findings.append(
|
|
|
|
|
_finding("NODE_ENRICHMENT_CHANGED_CLAIM", "Enrichment cannot alter an accepted assertion", claim_path)
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
if not set(old_claim.online_evidence) <= set(new_claim.online_evidence):
|
|
|
|
|
findings.append(
|
|
|
|
|
_finding("NODE_ENRICHMENT_REMOVED_ONLINE_EVIDENCE", "Enrichment cannot remove prior online evidence", claim_path)
|
|
|
|
|
)
|
|
|
|
|
old_evidence = set(old_claim.evidence)
|
|
|
|
|
if not old_evidence <= set(new_claim.evidence):
|
|
|
|
|
findings.append(
|
|
|
|
|
_finding("NODE_ENRICHMENT_REMOVED_EVIDENCE", "Enrichment cannot remove accepted evidence", claim_path)
|
|
|
|
|
)
|
|
|
|
|
if new_claim.evidence != old_claim.evidence and new_claim.assessment.assessor_id != proposer_id:
|
|
|
|
|
findings.append(
|
|
|
|
|
_finding("ASSESSOR_ATTRIBUTION_MISMATCH", "New evidence requires an assessment by its proposer", claim_path)
|
|
|
|
|
)
|
|
|
|
|
if not set(previous.tags) <= set(updated.tags):
|
|
|
|
|
findings.append(_finding("NODE_ENRICHMENT_REMOVED_FACET", "Enrichment cannot remove existing tags", f"{path}.tags"))
|
|
|
|
|
for field in ("audience", "domain", "use_for"):
|
|
|
|
|
if not set(getattr(previous, field)) <= set(getattr(updated, field)):
|
|
|
|
|
findings.append(_finding("NODE_ENRICHMENT_REMOVED_FACET", "Enrichment cannot remove existing facets", f"{path}.{field}"))
|
|
|
|
|
return tuple(findings)
|
|
|
|
|
|
|
|
|
|
|
2026-10-14 19:21:35 +08:00
|
|
|
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
|
2026-10-16 20:02:29 +08:00
|
|
|
if operation.node is not None and operation.operation == "upsert_node"
|
2026-10-14 19:21:35 +08:00
|
|
|
}
|
|
|
|
|
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
|
2026-10-16 20:02:29 +08:00
|
|
|
# Source relevance is explicit: a source ingested for one project
|
|
|
|
|
# does not become shared knowledge merely because its record is
|
|
|
|
|
# stored in the canonical source registry.
|
|
|
|
|
source_domains = set(source.ingested_for)
|
2026-10-14 19:21:35 +08:00
|
|
|
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
|
2026-10-16 20:02:29 +08:00
|
|
|
previous_node = (
|
|
|
|
|
existing_nodes.get(operation.target_id or "")
|
|
|
|
|
if operation.operation == "update_node"
|
|
|
|
|
else None
|
|
|
|
|
)
|
2026-10-14 19:21:35 +08:00
|
|
|
eligibility_checks.append(((node.project_id,), "node", node.node_type.value))
|
2026-10-16 20:02:29 +08:00
|
|
|
if operation.operation == "upsert_node" and node.node_id in existing_nodes:
|
2026-10-14 19:21:35 +08:00
|
|
|
findings.append(
|
|
|
|
|
_finding(
|
|
|
|
|
"NODE_ID_EXISTS",
|
|
|
|
|
"Node IDs are stable and cannot be reused",
|
|
|
|
|
f"{path}.node.node_id",
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-10-16 20:02:29 +08:00
|
|
|
elif operation.operation == "update_node":
|
|
|
|
|
if previous_node is None:
|
|
|
|
|
findings.append(
|
|
|
|
|
_finding("NODE_NOT_FOUND", "Update target is not an accepted node", f"{path}.target_id")
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
findings.extend(
|
|
|
|
|
_validate_node_enrichment(previous_node, node, proposal.proposer_id, f"{path}.node")
|
|
|
|
|
)
|
2026-10-14 19:21:35 +08:00
|
|
|
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}]"
|
2026-10-16 20:02:29 +08:00
|
|
|
previous_claims = (
|
|
|
|
|
{item.claim_id: item for item in previous_node.evidence_claims}
|
|
|
|
|
if previous_node is not None
|
|
|
|
|
else {}
|
|
|
|
|
)
|
|
|
|
|
old_claim = previous_claims.get(claim.claim_id)
|
|
|
|
|
unchanged_prior_assessment = bool(
|
|
|
|
|
old_claim
|
|
|
|
|
and old_claim.text == claim.text
|
|
|
|
|
and old_claim.evidence_type == claim.evidence_type
|
|
|
|
|
and old_claim.evidence == claim.evidence
|
|
|
|
|
and old_claim.assessment == claim.assessment
|
|
|
|
|
)
|
|
|
|
|
if claim.assessment.assessor_id != proposal.proposer_id and not unchanged_prior_assessment:
|
2026-10-14 19:21:35 +08:00
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-10-16 20:02:29 +08:00
|
|
|
quoted_text = "\n".join(evidence.quote for evidence in claim.evidence)
|
|
|
|
|
unquoted_numbers = _numeric_tokens(claim.text) - _numeric_tokens(quoted_text)
|
|
|
|
|
if unquoted_numbers:
|
|
|
|
|
findings.append(
|
|
|
|
|
_finding(
|
|
|
|
|
"NUMERIC_CLAIM_UNGROUNDED",
|
|
|
|
|
"Claim contains numeric literals absent from attached evidence; "
|
|
|
|
|
"semantic review is required",
|
|
|
|
|
claim_path,
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-10-14 19:21:35 +08:00
|
|
|
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",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-16 20:02:29 +08:00
|
|
|
if operation.operation == "flag_stale" and operation.target_id is not None:
|
2026-10-14 19:21:35 +08:00
|
|
|
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",
|
2026-10-16 20:02:29 +08:00
|
|
|
"NODE_NOT_FOUND",
|
|
|
|
|
"NODE_ENRICHMENT_IDENTITY_CHANGED",
|
|
|
|
|
"NODE_ENRICHMENT_TIMESTAMP_INVALID",
|
|
|
|
|
"NODE_ENRICHMENT_SUMMARY_CHANGED",
|
|
|
|
|
"NODE_ENRICHMENT_REMOVED_CLAIM",
|
|
|
|
|
"NODE_ENRICHMENT_CHANGED_CLAIM",
|
|
|
|
|
"NODE_ENRICHMENT_REMOVED_ONLINE_EVIDENCE",
|
|
|
|
|
"NODE_ENRICHMENT_REMOVED_EVIDENCE",
|
|
|
|
|
"NODE_ENRICHMENT_REMOVED_FACET",
|
2026-10-14 19:21:35 +08:00
|
|
|
"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())
|