"""Deterministic proposal and evidence validation rules.""" from __future__ import annotations import hashlib import json import re import unicodedata from collections.abc import Iterable from copy import deepcopy from dataclasses import dataclass from typing import Any 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 _numeric_tokens(text: str) -> set[str]: """Return normalized numeric literals for a conservative evidence check.""" matches = re.findall(r"(? 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] 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) return hashlib.sha256(canonical_json(operations)).hexdigest() 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 # 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 ) 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)}) 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 _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) 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 and operation.operation == "upsert_node" } 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 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) 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 previous_node = ( existing_nodes.get(operation.target_id or "") if operation.operation == "update_node" else None ) eligibility_checks.append(((node.project_id,), "node", node.node_type.value)) if operation.operation == "upsert_node" and 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", ) ) 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") ) 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}]" 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: 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, ) ) 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, ) ) 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.operation == "flag_stale" and 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", "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", "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())