This commit is contained in:
1 parent
4db3f45164
commit
d25c04cdd2
175 files changed
+56243
-15
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Transport-independent GEWU contracts."""
|
||||
@@ -0,0 +1,894 @@
|
||||
"""Versioned public records shared by GEWU transports and adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from datetime import date, datetime
|
||||
from enum import StrEnum
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
FiniteFloat,
|
||||
StringConstraints,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
RecordId = Annotated[
|
||||
str,
|
||||
StringConstraints(
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
pattern=r"^[a-zA-Z0-9][a-zA-Z0-9._:-]*$",
|
||||
),
|
||||
]
|
||||
SearchFacetValue = Annotated[
|
||||
str,
|
||||
StringConstraints(min_length=1, max_length=100),
|
||||
]
|
||||
Sha256 = Annotated[str, StringConstraints(pattern=r"^[a-f0-9]{64}$")]
|
||||
GitCommitId = Annotated[str, StringConstraints(pattern=r"^(?:[a-f0-9]{40}|[a-f0-9]{64})$")]
|
||||
|
||||
|
||||
class StrictRecord(BaseModel):
|
||||
"""Base for public records: reject implicit coercion and unknown fields."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
|
||||
|
||||
schema_version: Literal[1] = 1
|
||||
|
||||
|
||||
class ProjectStatus(StrEnum):
|
||||
ACTIVE = "active"
|
||||
PAUSED = "paused"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
class ProjectRecord(StrictRecord):
|
||||
project_id: RecordId
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
status: ProjectStatus
|
||||
description: str = Field(default="", max_length=4000)
|
||||
owners: tuple[RecordId, ...] = ()
|
||||
north_star: str = Field(min_length=1, max_length=4000)
|
||||
key_questions: tuple[str, ...] = Field(min_length=1, max_length=30)
|
||||
watch_list: tuple[str, ...] = Field(default=(), max_length=100)
|
||||
default_includes: tuple[RecordId, ...] = ("shared",)
|
||||
taxonomy_extensions: dict[str, tuple[str, ...]] = Field(default_factory=dict, max_length=100)
|
||||
autonomy_policy_id: RecordId
|
||||
|
||||
@field_validator("project_id")
|
||||
@classmethod
|
||||
def project_id_is_slug(cls, value: str) -> str:
|
||||
if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", value):
|
||||
raise ValueError("Project IDs must be lowercase hyphen-separated slugs")
|
||||
return value
|
||||
|
||||
|
||||
class ProjectLens(StrictRecord):
|
||||
project_id: RecordId
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
status: ProjectStatus
|
||||
north_star: str = Field(min_length=1, max_length=4000)
|
||||
key_questions: tuple[str, ...] = Field(min_length=1, max_length=30)
|
||||
watch_list: tuple[str, ...] = Field(default=(), max_length=100)
|
||||
taxonomy_extensions: dict[str, tuple[str, ...]] = Field(default_factory=dict, max_length=100)
|
||||
|
||||
|
||||
class ProjectContextResponse(StrictRecord):
|
||||
lens: ProjectLens
|
||||
|
||||
|
||||
class ReliabilityGrade(StrEnum):
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
|
||||
|
||||
class AutoAcceptPolicy(StrictRecord):
|
||||
policy_id: RecordId
|
||||
policy_version: int = Field(ge=1)
|
||||
project_id: RecordId
|
||||
automatic_node_types: tuple[
|
||||
Literal[
|
||||
"finding",
|
||||
"problem",
|
||||
"technique",
|
||||
"case",
|
||||
"entity",
|
||||
"event",
|
||||
"concept",
|
||||
"datapoint",
|
||||
"synthesis",
|
||||
],
|
||||
...,
|
||||
] = ()
|
||||
automatic_edge_types: tuple[
|
||||
Literal[
|
||||
"solves",
|
||||
"addresses",
|
||||
"causes",
|
||||
"supports",
|
||||
"contradicts",
|
||||
"instance_of",
|
||||
"part_of",
|
||||
"precedes",
|
||||
"supersedes",
|
||||
"compared_with",
|
||||
"derived_from",
|
||||
"applies_to",
|
||||
],
|
||||
...,
|
||||
] = ()
|
||||
automatic_source_registration: bool = False
|
||||
require_supported_assessment: bool = True
|
||||
require_owner_decision_for: tuple[
|
||||
Literal["delete", "merge", "promote", "intent", "policy"], ...
|
||||
] = (
|
||||
"delete",
|
||||
"merge",
|
||||
"promote",
|
||||
"intent",
|
||||
"policy",
|
||||
)
|
||||
|
||||
|
||||
class SourceRevisionRecord(StrictRecord):
|
||||
source_id: RecordId
|
||||
revision_id: RecordId
|
||||
artifact_sha256: Sha256 | None = None
|
||||
media_type: str = Field(min_length=1, max_length=128)
|
||||
byte_length: int = Field(ge=0)
|
||||
captured_at: datetime
|
||||
capture_method: Literal[
|
||||
"unspecified", "local-file", "url-fetch", "pasted-text", "notebooklm-export", "transcript"
|
||||
] = "unspecified"
|
||||
original_url: str | None = Field(default=None, max_length=4000)
|
||||
final_url: str | None = Field(default=None, max_length=4000)
|
||||
title: str = Field(min_length=1, max_length=500)
|
||||
authors: tuple[str, ...] = Field(default=(), max_length=50)
|
||||
publisher: str | None = Field(default=None, max_length=300)
|
||||
publication_date: datetime | None = None
|
||||
license: str = Field(min_length=1, max_length=500)
|
||||
source_type: Literal[
|
||||
"paper", "review", "press", "vendor", "internal", "talk", "export", "other"
|
||||
]
|
||||
reliability_grade: ReliabilityGrade
|
||||
ingested_for: tuple[RecordId, ...] = Field(min_length=1, max_length=50)
|
||||
archive_status: Literal["archived", "reference-only", "capture-failed"]
|
||||
|
||||
@field_validator("captured_at", "publication_date")
|
||||
@classmethod
|
||||
def timestamps_are_utc(cls, value: datetime | None) -> datetime | None:
|
||||
offset = value.utcoffset() if value is not None and value.tzinfo is not None else None
|
||||
if value is not None and (offset is None or offset.total_seconds() != 0):
|
||||
raise ValueError("timestamps must be timezone-aware UTC")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def archive_metadata_matches_artifact(self) -> SourceRevisionRecord:
|
||||
if self.archive_status == "archived":
|
||||
if self.artifact_sha256 is None or self.byte_length < 1:
|
||||
raise ValueError("archived sources require a non-empty archived artifact")
|
||||
elif self.artifact_sha256 is not None or self.byte_length != 0:
|
||||
raise ValueError("unarchived sources cannot claim artifact bytes or a digest")
|
||||
if self.archive_status != "archived" and not self.original_url:
|
||||
raise ValueError("unarchived source records require an original URL")
|
||||
return self
|
||||
|
||||
|
||||
class ExtractionBlock(StrictRecord):
|
||||
block_id: RecordId
|
||||
ordinal: int = Field(ge=0)
|
||||
original_text: str = Field(max_length=100_000)
|
||||
normalized_text: str = Field(max_length=100_000)
|
||||
normalization_map: tuple[tuple[int, int], ...] = Field(max_length=100_000)
|
||||
page: int | None = Field(default=None, ge=1)
|
||||
time_start_ms: int | None = Field(default=None, ge=0)
|
||||
time_end_ms: int | None = Field(default=None, ge=0)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_normalization_map(self) -> ExtractionBlock:
|
||||
if len(self.normalization_map) != len(self.normalized_text):
|
||||
raise ValueError("normalization map must cover every normalized code point")
|
||||
if (self.time_start_ms is None) != (self.time_end_ms is None):
|
||||
raise ValueError("transcript cue start and end times must be provided together")
|
||||
if self.time_start_ms is not None and self.time_end_ms is not None:
|
||||
if self.time_end_ms <= self.time_start_ms:
|
||||
raise ValueError("transcript cue end must follow its start")
|
||||
previous_start = -1
|
||||
for start, end in self.normalization_map:
|
||||
if start < previous_start or end <= start or end > len(self.original_text):
|
||||
raise ValueError("normalization map contains an invalid original span")
|
||||
previous_start = start
|
||||
return self
|
||||
|
||||
|
||||
class ExtractionRecord(StrictRecord):
|
||||
extraction_id: Sha256
|
||||
source_revision_id: RecordId
|
||||
parser_name: str = Field(min_length=1, max_length=100)
|
||||
parser_version: str = Field(min_length=1, max_length=100)
|
||||
parser_config_sha256: Sha256
|
||||
normalization_profile: Literal["unicode-nfc-lf-v1"]
|
||||
status: Literal["complete", "partial", "unavailable"]
|
||||
blocks: tuple[ExtractionBlock, ...] = Field(max_length=100_000)
|
||||
warnings: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class Claim(StrictRecord):
|
||||
claim_id: RecordId
|
||||
text: str = Field(min_length=1, max_length=4000)
|
||||
evidence_type: Literal["measured", "reported", "opinion", "vendor_marketing"]
|
||||
evidence: tuple[EvidenceReference, ...] = Field(min_length=1, max_length=20)
|
||||
assessment: SupportAssessment
|
||||
|
||||
@model_validator(mode="after")
|
||||
def assessment_matches_evidence(self) -> Claim:
|
||||
expected_digests = {item.assertion_digest for item in self.evidence}
|
||||
if expected_digests != {self.assessment.assertion_digest}:
|
||||
raise ValueError("support assessment must bind the claim's exact assertion digest")
|
||||
return self
|
||||
|
||||
|
||||
class NodeType(StrEnum):
|
||||
FINDING = "finding"
|
||||
PROBLEM = "problem"
|
||||
TECHNIQUE = "technique"
|
||||
CASE = "case"
|
||||
ENTITY = "entity"
|
||||
EVENT = "event"
|
||||
CONCEPT = "concept"
|
||||
DATAPOINT = "datapoint"
|
||||
SYNTHESIS = "synthesis"
|
||||
|
||||
|
||||
class ReviewState(StrEnum):
|
||||
DRAFT = "draft"
|
||||
REVIEWED = "reviewed"
|
||||
|
||||
|
||||
class Lifecycle(StrEnum):
|
||||
ACTIVE = "active"
|
||||
SUPERSEDED = "superseded"
|
||||
|
||||
|
||||
class Confidence(StrEnum):
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
|
||||
|
||||
class LensFit(StrictRecord):
|
||||
score: FiniteFloat = Field(ge=0, le=1)
|
||||
horizon: Literal["H1", "H2", "H3"]
|
||||
key_question: str | None = Field(default=None, max_length=1000)
|
||||
reason: str = Field(min_length=1, max_length=1000)
|
||||
|
||||
|
||||
class KnowledgeNode(StrictRecord):
|
||||
node_id: RecordId
|
||||
project_id: RecordId
|
||||
node_type: NodeType
|
||||
title: str = Field(min_length=1, max_length=300)
|
||||
summary: str = Field(min_length=1, max_length=8000)
|
||||
created: datetime
|
||||
updated: datetime
|
||||
review_state: ReviewState
|
||||
lifecycle: Lifecycle
|
||||
confidence: Confidence
|
||||
claim_support: Literal["pending", "supported", "unsupported"]
|
||||
assumptions: tuple[Claim, ...] = Field(default=(), max_length=100)
|
||||
limitations: tuple[Claim, ...] = Field(default=(), max_length=100)
|
||||
tags: tuple[str, ...] = Field(default=(), max_length=100)
|
||||
audience: tuple[str, ...] = Field(default=(), max_length=20)
|
||||
domain: tuple[str, ...] = Field(default=(), max_length=20)
|
||||
use_for: tuple[str, ...] = Field(default=(), max_length=20)
|
||||
lens_fit: dict[RecordId, LensFit] = Field(default_factory=dict, max_length=50)
|
||||
run_id: RecordId
|
||||
agent_id: RecordId
|
||||
model_id: str = Field(min_length=1, max_length=200)
|
||||
claims: tuple[Claim, ...] = Field(min_length=1, max_length=100)
|
||||
as_of: datetime | None = None
|
||||
stale_after: datetime | None = None
|
||||
supersedes: tuple[RecordId, ...] = ()
|
||||
superseded_by: tuple[RecordId, ...] = ()
|
||||
aliases: tuple[str, ...] = Field(default=(), max_length=100)
|
||||
merged_from: tuple[RecordId, ...] = Field(default=(), max_length=100)
|
||||
value: FiniteFloat | None = None
|
||||
unit: str | None = Field(default=None, max_length=100)
|
||||
sample_size: int | None = Field(default=None, ge=0)
|
||||
reporting_year: int | None = Field(default=None, ge=1, le=9999)
|
||||
demographic: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
@property
|
||||
def evidence_claims(self) -> tuple[Claim, ...]:
|
||||
"""All factual statements that carry citation and assessment requirements."""
|
||||
|
||||
return (*self.claims, *self.assumptions, *self.limitations)
|
||||
|
||||
@field_validator("aliases")
|
||||
@classmethod
|
||||
def normalize_aliases(cls, values: tuple[str, ...]) -> tuple[str, ...]:
|
||||
return tuple(" ".join(unicodedata.normalize("NFC", value).split()) for value in values)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def datapoint_has_freshness(self) -> KnowledgeNode:
|
||||
claim_ids = [claim.claim_id for claim in self.evidence_claims]
|
||||
if len(claim_ids) != len(set(claim_ids)):
|
||||
raise ValueError("claim IDs must be unique across claims, assumptions and limitations")
|
||||
normalized_aliases = [" ".join(alias.split()).casefold() for alias in self.aliases]
|
||||
if any(not alias for alias in normalized_aliases):
|
||||
raise ValueError("aliases cannot be empty")
|
||||
if len(normalized_aliases) != len(set(normalized_aliases)):
|
||||
raise ValueError("aliases must be unique ignoring case and whitespace")
|
||||
normalized_title = " ".join(self.title.split()).casefold()
|
||||
if normalized_title in normalized_aliases:
|
||||
raise ValueError("aliases cannot repeat the node title")
|
||||
if self.node_type == NodeType.DATAPOINT and (
|
||||
self.as_of is None
|
||||
or self.stale_after is None
|
||||
or self.value is None
|
||||
or self.unit is None
|
||||
):
|
||||
raise ValueError("datapoints require as_of, stale_after, value and unit")
|
||||
if self.as_of and self.stale_after and self.stale_after <= self.as_of:
|
||||
raise ValueError("stale_after must be later than as_of")
|
||||
if self.updated < self.created:
|
||||
raise ValueError("updated must not precede created")
|
||||
if self.claim_support != "supported":
|
||||
raise ValueError("accepted factual nodes must carry supported claim status")
|
||||
if self.lifecycle == Lifecycle.SUPERSEDED and not self.superseded_by:
|
||||
raise ValueError("superseded nodes require a superseded_by target")
|
||||
return self
|
||||
|
||||
@field_validator("created", "updated", "as_of", "stale_after")
|
||||
@classmethod
|
||||
def node_timestamps_are_utc(cls, value: datetime | None) -> datetime | None:
|
||||
offset = value.utcoffset() if value is not None and value.tzinfo is not None else None
|
||||
if value is not None and (offset is None or offset.total_seconds() != 0):
|
||||
raise ValueError("timestamps must be timezone-aware UTC")
|
||||
return value
|
||||
|
||||
|
||||
class EdgeEndpoint(StrictRecord):
|
||||
kind: Literal["node", "source", "project"]
|
||||
id: RecordId
|
||||
|
||||
|
||||
class TypedEdge(StrictRecord):
|
||||
edge_id: RecordId
|
||||
edge_type: Literal[
|
||||
"solves",
|
||||
"addresses",
|
||||
"causes",
|
||||
"supports",
|
||||
"contradicts",
|
||||
"instance_of",
|
||||
"part_of",
|
||||
"precedes",
|
||||
"supersedes",
|
||||
"compared_with",
|
||||
"derived_from",
|
||||
"applies_to",
|
||||
]
|
||||
source: EdgeEndpoint
|
||||
target: EdgeEndpoint
|
||||
conditions: str | None = Field(default=None, max_length=2000)
|
||||
evidence: tuple[EvidenceReference, ...] = ()
|
||||
interpretation_rationale: str | None = Field(default=None, max_length=4000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def substantive_edge_has_basis(self) -> TypedEdge:
|
||||
if self.edge_type == "derived_from" and (
|
||||
self.source.kind != "node" or self.target.kind != "source"
|
||||
):
|
||||
raise ValueError("derived_from connects a knowledge node to a source revision")
|
||||
if self.edge_type == "applies_to" and (
|
||||
self.source.kind != "node" or self.target.kind != "project"
|
||||
):
|
||||
raise ValueError("applies_to connects a knowledge node to a project")
|
||||
if self.edge_type not in {"derived_from", "applies_to"} and (
|
||||
self.source.kind != "node" or self.target.kind != "node"
|
||||
):
|
||||
raise ValueError("this edge type connects two knowledge nodes")
|
||||
if self.edge_type in {"solves", "addresses", "causes", "supports", "contradicts"}:
|
||||
if not self.evidence and not self.interpretation_rationale:
|
||||
raise ValueError(
|
||||
"substantive edges require evidence or labeled interpretive rationale"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class ProposalOperation(StrictRecord):
|
||||
operation: Literal["register_source", "upsert_node", "add_edge", "flag_stale"]
|
||||
source: SourceRevisionRecord | None = None
|
||||
node: KnowledgeNode | None = None
|
||||
edge: TypedEdge | None = None
|
||||
target_id: RecordId | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def payload_matches_operation(self) -> ProposalOperation:
|
||||
payloads = {
|
||||
"register_source": self.source,
|
||||
"upsert_node": self.node,
|
||||
"add_edge": self.edge,
|
||||
}
|
||||
expected = payloads.get(self.operation)
|
||||
supplied = sum(value is not None for value in (self.source, self.node, self.edge))
|
||||
if self.operation == "flag_stale":
|
||||
if supplied or self.target_id is None:
|
||||
raise ValueError(
|
||||
"flag_stale uses an explicit target elsewhere, not a record payload"
|
||||
)
|
||||
elif supplied != 1 or expected is None:
|
||||
raise ValueError("operation must carry exactly its matching typed payload")
|
||||
elif self.target_id is not None:
|
||||
raise ValueError("target_id is only valid for flag_stale")
|
||||
return self
|
||||
|
||||
|
||||
class ProposalRevision(StrictRecord):
|
||||
proposal_id: RecordId
|
||||
revision: int = Field(ge=1)
|
||||
initiating_project_id: RecordId
|
||||
scope: Scope
|
||||
base_commit: GitCommitId
|
||||
payload_digest: Sha256
|
||||
proposer_id: RecordId
|
||||
run_id: RecordId
|
||||
operations: tuple[ProposalOperation, ...] = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
class Scope(StrictRecord):
|
||||
"""Project context plus optional project includes; all-project access is deliberate."""
|
||||
|
||||
projects: tuple[RecordId, ...] = Field(min_length=1, max_length=50)
|
||||
include_projects: tuple[RecordId, ...] | None = Field(default=None, max_length=50)
|
||||
include_shared: bool = True
|
||||
all_projects: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_all_projects(self) -> Scope:
|
||||
if self.all_projects and self.projects != ("all",):
|
||||
raise ValueError("all_projects requires projects=['all']")
|
||||
if not self.all_projects and "all" in self.projects:
|
||||
raise ValueError("projects=['all'] requires all_projects=true")
|
||||
if len(set(self.projects)) != len(self.projects):
|
||||
raise ValueError("scope projects must be unique")
|
||||
if self.include_projects is not None:
|
||||
if len(set(self.include_projects)) != len(self.include_projects):
|
||||
raise ValueError("scope includes must be unique")
|
||||
if set(self.projects) & set(self.include_projects):
|
||||
raise ValueError("scope includes cannot repeat project context")
|
||||
if "shared" in self.include_projects:
|
||||
raise ValueError("shared scope must use include_shared")
|
||||
if self.all_projects and self.include_projects is not None:
|
||||
raise ValueError("all_projects cannot be combined with project includes")
|
||||
return self
|
||||
|
||||
|
||||
class SearchRequest(StrictRecord):
|
||||
query: str = Field(default="", max_length=1000)
|
||||
scope: Scope
|
||||
node_types: tuple[NodeType, ...] = Field(default=(), max_length=9)
|
||||
tags: tuple[SearchFacetValue, ...] = Field(default=(), max_length=20)
|
||||
audience: tuple[SearchFacetValue, ...] = Field(default=(), max_length=20)
|
||||
use_for: tuple[SearchFacetValue, ...] = Field(default=(), max_length=20)
|
||||
domain: tuple[SearchFacetValue, ...] = Field(default=(), max_length=20)
|
||||
confidences: tuple[Confidence, ...] = Field(default=(), max_length=3)
|
||||
as_of_from: date | None = None
|
||||
as_of_to: date | None = None
|
||||
stale_only: bool = False
|
||||
limit: int = Field(default=20, ge=1, le=50)
|
||||
cursor: str | None = Field(default=None, max_length=512)
|
||||
budget_tokens: int = Field(ge=256, le=50_000)
|
||||
|
||||
@field_validator("query")
|
||||
@classmethod
|
||||
def normalize_query(cls, value: str) -> str:
|
||||
return value.strip()
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_query_or_type_filter(self) -> SearchRequest:
|
||||
if not self.query and not self.node_types:
|
||||
raise ValueError("search requires text or at least one knowledge type")
|
||||
if self.as_of_from is not None and self.as_of_to is not None:
|
||||
if self.as_of_from > self.as_of_to:
|
||||
raise ValueError("as_of_from must not be later than as_of_to")
|
||||
return self
|
||||
|
||||
@field_validator("node_types")
|
||||
@classmethod
|
||||
def search_node_types_are_unique(cls, value: tuple[NodeType, ...]) -> tuple[NodeType, ...]:
|
||||
if len(value) != len(set(value)):
|
||||
raise ValueError("search node types must be unique")
|
||||
return value
|
||||
|
||||
@field_validator("confidences")
|
||||
@classmethod
|
||||
def search_confidences_are_unique(cls, value: tuple[Confidence, ...]) -> tuple[Confidence, ...]:
|
||||
if len(value) != len(set(value)):
|
||||
raise ValueError("search confidences must be unique")
|
||||
return value
|
||||
|
||||
@field_validator("tags", "audience", "use_for", "domain")
|
||||
@classmethod
|
||||
def search_facet_values_are_unique_and_trimmed(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
||||
if any(item != item.strip() for item in value):
|
||||
raise ValueError("search facet values cannot have leading or trailing whitespace")
|
||||
if len(value) != len(set(value)):
|
||||
raise ValueError("search facet values must be unique")
|
||||
return value
|
||||
|
||||
|
||||
class NeighborRequest(StrictRecord):
|
||||
node_id: RecordId
|
||||
scope: Scope
|
||||
edge_types: tuple[
|
||||
Literal[
|
||||
"solves",
|
||||
"addresses",
|
||||
"causes",
|
||||
"supports",
|
||||
"contradicts",
|
||||
"instance_of",
|
||||
"part_of",
|
||||
"precedes",
|
||||
"supersedes",
|
||||
"compared_with",
|
||||
"derived_from",
|
||||
"applies_to",
|
||||
],
|
||||
...,
|
||||
] = Field(default=(), max_length=12)
|
||||
limit: int = Field(default=20, ge=1, le=50)
|
||||
cursor: str | None = Field(default=None, max_length=512)
|
||||
budget_tokens: int = Field(ge=256, le=50_000)
|
||||
|
||||
|
||||
class SourceListRequest(StrictRecord):
|
||||
scope: Scope
|
||||
limit: int = Field(default=20, ge=1, le=50)
|
||||
cursor: str | None = Field(default=None, max_length=512)
|
||||
budget_tokens: int = Field(ge=256, le=50_000)
|
||||
|
||||
|
||||
class Locator(StrictRecord):
|
||||
kind: Literal["text_span", "pdf_page_span", "transcript_timestamp_span"]
|
||||
block_id: RecordId
|
||||
start: int = Field(ge=0)
|
||||
end: int = Field(gt=0)
|
||||
page: int | None = Field(default=None, ge=1)
|
||||
time_start_ms: int | None = Field(default=None, ge=0)
|
||||
time_end_ms: int | None = Field(default=None, ge=0)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_span(self) -> Locator:
|
||||
if self.end <= self.start:
|
||||
raise ValueError("locator end must be greater than start")
|
||||
if self.kind == "pdf_page_span" and self.page is None:
|
||||
raise ValueError("PDF locators require a one-based page")
|
||||
if self.kind != "pdf_page_span" and self.page is not None:
|
||||
raise ValueError("only PDF locators can specify a page")
|
||||
if self.kind == "transcript_timestamp_span":
|
||||
if self.time_start_ms is None or self.time_end_ms is None:
|
||||
raise ValueError("transcript locators require cue start and end times")
|
||||
if self.time_end_ms <= self.time_start_ms:
|
||||
raise ValueError("transcript locator end must follow its start")
|
||||
elif self.time_start_ms is not None or self.time_end_ms is not None:
|
||||
raise ValueError("only transcript locators can specify cue times")
|
||||
return self
|
||||
|
||||
|
||||
class EvidenceReference(StrictRecord):
|
||||
source_revision_id: RecordId
|
||||
extraction_id: Sha256
|
||||
assertion_path: str = Field(min_length=1, max_length=256)
|
||||
assertion_digest: Sha256
|
||||
quote: str = Field(min_length=1, max_length=8000)
|
||||
locator: Locator
|
||||
normalization_profile: Literal["unicode-nfc-lf-v1"]
|
||||
|
||||
|
||||
class SupportStatus(StrEnum):
|
||||
SUPPORTED = "supported"
|
||||
UNSUPPORTED = "unsupported"
|
||||
UNAVAILABLE = "unavailable"
|
||||
|
||||
|
||||
class SupportAssessment(StrictRecord):
|
||||
assessment_id: RecordId
|
||||
assertion_digest: Sha256
|
||||
evidence_digests: tuple[Sha256, ...] = Field(min_length=1, max_length=20)
|
||||
assessor_id: RecordId
|
||||
run_id: RecordId
|
||||
method: str = Field(min_length=1, max_length=100)
|
||||
status: SupportStatus
|
||||
rationale: str = Field(min_length=1, max_length=4000)
|
||||
|
||||
|
||||
class ProposalState(StrEnum):
|
||||
STAGED = "staged"
|
||||
VALIDATING = "validating"
|
||||
NEEDS_REVIEW = "needs-review"
|
||||
ACCEPTED = "accepted"
|
||||
REJECTED = "rejected"
|
||||
WITHDRAWN = "withdrawn"
|
||||
|
||||
|
||||
class Finding(StrictRecord):
|
||||
code: RecordId
|
||||
message: str = Field(min_length=1, max_length=1000)
|
||||
path: str | None = Field(default=None, max_length=256)
|
||||
|
||||
|
||||
class ProposalReceipt(StrictRecord):
|
||||
proposal_id: RecordId
|
||||
revision: int = Field(ge=1)
|
||||
payload_digest: Sha256
|
||||
state: ProposalState
|
||||
outcome: Literal["accepted", "held", "rejected", "validation-failed"]
|
||||
commit_id: GitCommitId | None = None
|
||||
accepted_node_ids: tuple[RecordId, ...] = ()
|
||||
findings: tuple[Finding, ...] = ()
|
||||
decided_at: datetime | None = None
|
||||
|
||||
|
||||
class ProposalStatus(StrictRecord):
|
||||
proposal_id: RecordId
|
||||
revision: int = Field(ge=1)
|
||||
lifecycle: Literal[
|
||||
"staged",
|
||||
"evaluated",
|
||||
"publishing",
|
||||
"published-unreconciled",
|
||||
"accepted",
|
||||
"withdrawn",
|
||||
]
|
||||
validation_status: Literal["eligible", "needs-review", "invalid", "conflict"] | None = None
|
||||
eligible_for_auto_accept: bool | None = None
|
||||
findings: tuple[Finding, ...] = Field(default=(), max_length=200)
|
||||
receipt: ProposalReceipt | None = None
|
||||
canonical_revision: GitCommitId | None = None
|
||||
|
||||
|
||||
class RunModulePin(StrictRecord):
|
||||
module_id: RecordId
|
||||
implementation_version: str = Field(min_length=1, max_length=64)
|
||||
sha256: Sha256
|
||||
|
||||
|
||||
class RunCoverageItem(StrictRecord):
|
||||
item: str = Field(min_length=1, max_length=500)
|
||||
reason: str = Field(min_length=1, max_length=1000)
|
||||
|
||||
|
||||
class RunCoverage(StrictRecord):
|
||||
selected: tuple[str, ...] = Field(default=(), max_length=10_000)
|
||||
included: tuple[str, ...] = Field(default=(), max_length=10_000)
|
||||
excluded: tuple[RunCoverageItem, ...] = Field(default=(), max_length=10_000)
|
||||
skipped: tuple[RunCoverageItem, ...] = Field(default=(), max_length=10_000)
|
||||
|
||||
|
||||
class RunSourceReference(StrictRecord):
|
||||
source_revision_id: RecordId
|
||||
artifact_sha256: Sha256 | None = None
|
||||
extraction_id: Sha256 | None = None
|
||||
status: Literal["selected", "captured", "extracted", "unavailable", "failed"]
|
||||
|
||||
|
||||
class RunOperationReference(StrictRecord):
|
||||
operation_id: RecordId
|
||||
tool_name: RecordId
|
||||
input_sha256: Sha256
|
||||
status: Literal["pending", "uncertain", "complete", "failed"]
|
||||
|
||||
|
||||
class RunProposalReference(StrictRecord):
|
||||
proposal_id: RecordId
|
||||
revision: int = Field(ge=1)
|
||||
status: Literal[
|
||||
"staged",
|
||||
"evaluated",
|
||||
"eligible",
|
||||
"needs-review",
|
||||
"invalid",
|
||||
"conflict",
|
||||
"publishing",
|
||||
"accepted",
|
||||
"rejected",
|
||||
"withdrawn",
|
||||
"uncertain",
|
||||
]
|
||||
receipt: ProposalReceipt | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def receipt_matches_proposal(self) -> RunProposalReference:
|
||||
if self.status == "accepted":
|
||||
if (
|
||||
self.receipt is None
|
||||
or self.receipt.proposal_id != self.proposal_id
|
||||
or self.receipt.revision != self.revision
|
||||
or self.receipt.state != ProposalState.ACCEPTED
|
||||
):
|
||||
raise ValueError("accepted run proposal requires its matching service receipt")
|
||||
return self
|
||||
|
||||
|
||||
class RunState(StrEnum):
|
||||
ACTIVE = "active"
|
||||
COMPLETED = "completed"
|
||||
BLOCKED = "blocked"
|
||||
|
||||
|
||||
class RunCreateRequest(StrictRecord):
|
||||
run_id: RecordId
|
||||
objective: str = Field(min_length=1, max_length=4000)
|
||||
scope: Scope
|
||||
lens: str = Field(min_length=1, max_length=2000)
|
||||
pack_id: RecordId
|
||||
pack_version: str = Field(min_length=1, max_length=64)
|
||||
pack_snapshot_digest: Sha256
|
||||
module_pins: tuple[RunModulePin, ...] = Field(min_length=1, max_length=100)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def explicit_scope_and_unique_modules(self) -> RunCreateRequest:
|
||||
if self.scope.all_projects:
|
||||
raise ValueError("runs require explicit project scope")
|
||||
module_ids = tuple(module.module_id for module in self.module_pins)
|
||||
if len(set(module_ids)) != len(module_ids):
|
||||
raise ValueError("run module pins must have unique IDs")
|
||||
if not self.objective.strip() or not self.lens.strip():
|
||||
raise ValueError("run objective and lens must not be blank")
|
||||
return self
|
||||
|
||||
|
||||
class RunCheckpointRequest(StrictRecord):
|
||||
run_id: RecordId
|
||||
expected_revision: int = Field(ge=0, le=2**31 - 2)
|
||||
state: RunState = RunState.ACTIVE
|
||||
stage: RecordId
|
||||
sources: tuple[RunSourceReference, ...] = Field(default=(), max_length=500)
|
||||
coverage: RunCoverage = Field(default_factory=RunCoverage)
|
||||
budgets: dict[RecordId, Annotated[int, Field(ge=0)]] = Field(
|
||||
default_factory=dict, max_length=100
|
||||
)
|
||||
pending_operations: tuple[RunOperationReference, ...] = Field(default=(), max_length=1000)
|
||||
proposals: tuple[RunProposalReference, ...] = Field(default=(), max_length=1000)
|
||||
output_hashes: tuple[Sha256, ...] = Field(default=(), max_length=1000)
|
||||
|
||||
|
||||
class RunRecord(StrictRecord):
|
||||
run_id: RecordId
|
||||
revision: int = Field(ge=0)
|
||||
state: RunState
|
||||
objective: str = Field(min_length=1, max_length=4000)
|
||||
scope: Scope
|
||||
lens: str = Field(min_length=1, max_length=2000)
|
||||
pack_id: RecordId
|
||||
pack_version: str = Field(min_length=1, max_length=64)
|
||||
pack_snapshot_digest: Sha256
|
||||
module_pins: tuple[RunModulePin, ...] = Field(min_length=1, max_length=100)
|
||||
stage: RecordId
|
||||
sources: tuple[RunSourceReference, ...] = Field(default=(), max_length=500)
|
||||
coverage: RunCoverage = Field(default_factory=RunCoverage)
|
||||
budgets: dict[RecordId, Annotated[int, Field(ge=0)]] = Field(
|
||||
default_factory=dict, max_length=100
|
||||
)
|
||||
pending_operations: tuple[RunOperationReference, ...] = Field(default=(), max_length=1000)
|
||||
proposals: tuple[RunProposalReference, ...] = Field(default=(), max_length=1000)
|
||||
output_hashes: tuple[Sha256, ...] = Field(default=(), max_length=1000)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
checkpoint_digest: Sha256 | None = None
|
||||
|
||||
|
||||
class ChangedFile(StrictRecord):
|
||||
path: str = Field(min_length=1, max_length=500)
|
||||
content_sha256: Sha256
|
||||
|
||||
|
||||
class PolicyBinding(StrictRecord):
|
||||
project_id: RecordId
|
||||
policy_id: RecordId
|
||||
policy_version: int = Field(ge=1)
|
||||
|
||||
|
||||
class OwnerDecisionReference(StrictRecord):
|
||||
challenge_id: RecordId
|
||||
owner_principal_id: RecordId
|
||||
owner_session_id: Sha256
|
||||
decision_digest: Sha256
|
||||
domains: tuple[RecordId, ...] = Field(min_length=1, max_length=100)
|
||||
grant_version: int = Field(ge=1)
|
||||
decision: Literal["authorize"] = "authorize"
|
||||
rationale: str = Field(min_length=1, max_length=2000)
|
||||
decided_at: datetime
|
||||
|
||||
|
||||
class AcceptanceManifest(StrictRecord):
|
||||
proposal_id: RecordId
|
||||
proposal_revision: int = Field(ge=1)
|
||||
payload_digest: Sha256
|
||||
before_revision: GitCommitId
|
||||
changed_files: tuple[ChangedFile, ...] = Field(min_length=1, max_length=200)
|
||||
accepted_node_ids: tuple[RecordId, ...] = ()
|
||||
actor_id: RecordId
|
||||
credential_id: RecordId
|
||||
policy_bindings: tuple[PolicyBinding, ...]
|
||||
owner_decisions: tuple[OwnerDecisionReference, ...] = Field(default=(), max_length=100)
|
||||
decided_at: datetime
|
||||
|
||||
|
||||
class ErrorCode(StrEnum):
|
||||
SCOPE_REQUIRED = "SCOPE_REQUIRED"
|
||||
OUT_OF_SCOPE = "OUT_OF_SCOPE"
|
||||
UNAUTHORIZED = "UNAUTHORIZED"
|
||||
OWNER_DECISION_REQUIRED = "OWNER_DECISION_REQUIRED"
|
||||
POLICY_MISSING = "POLICY_MISSING"
|
||||
EVIDENCE_MISMATCH = "EVIDENCE_MISMATCH"
|
||||
SUPPORT_UNAVAILABLE = "SUPPORT_UNAVAILABLE"
|
||||
BASE_CONFLICT = "BASE_CONFLICT"
|
||||
IDEMPOTENCY_CONFLICT = "IDEMPOTENCY_CONFLICT"
|
||||
BUDGET_TOO_SMALL = "BUDGET_TOO_SMALL"
|
||||
UPLOAD_EXPIRED = "UPLOAD_EXPIRED"
|
||||
BUSY = "BUSY"
|
||||
RECOVERY_REQUIRED = "RECOVERY_REQUIRED"
|
||||
|
||||
|
||||
class DomainError(StrictRecord):
|
||||
code: ErrorCode
|
||||
message: str = Field(min_length=1, max_length=1000)
|
||||
findings: tuple[Finding, ...] = ()
|
||||
retryable: bool
|
||||
request_id: RecordId
|
||||
|
||||
|
||||
class BudgetProfile(StrictRecord):
|
||||
tokenizer: str = Field(min_length=1, max_length=100)
|
||||
encoding: str = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
class ReadEnvelope(StrictRecord):
|
||||
api_version: Literal["v1"] = "v1"
|
||||
request_id: RecordId
|
||||
resolved_scope: Scope
|
||||
data: tuple[dict[str, object], ...]
|
||||
citations: tuple[EvidenceReference, ...] = ()
|
||||
warnings: tuple[Finding, ...] = ()
|
||||
omissions: tuple[Finding, ...] = ()
|
||||
canonical_revision: GitCommitId | None
|
||||
index_revision: GitCommitId | None
|
||||
retrieval_mode: Literal["keyword", "hybrid", "canonical"]
|
||||
budget_profile: BudgetProfile
|
||||
tokens_used: int = Field(ge=0)
|
||||
next_cursor: str | None = None
|
||||
|
||||
|
||||
class ServiceDiagnostics(StrictRecord):
|
||||
service_version: str = Field(min_length=1, max_length=64)
|
||||
api_version: Literal["v1"] = "v1"
|
||||
catalog_sha256: Sha256
|
||||
transport: Literal["http", "mcp"]
|
||||
authentication: Literal["bearer", "browser_session"]
|
||||
roles: tuple[RecordId, ...] = ()
|
||||
authorized_projects: tuple[RecordId, ...] = ()
|
||||
selected_scope: Scope | None = None
|
||||
capabilities: tuple[RecordId, ...] = ()
|
||||
authorized_operations: tuple[RecordId, ...] = ()
|
||||
connection_status: Literal["authenticated"] = "authenticated"
|
||||
workflow_status: Literal["not_verified"] = "not_verified"
|
||||
max_upload_bytes: int = Field(ge=1)
|
||||
upload_chunk_bytes: int = Field(ge=1)
|
||||
supported_media_types: tuple[str, ...] = Field(default=(), max_length=20)
|
||||
canonical_revision: GitCommitId
|
||||
index_state: Literal["current", "lagging", "not_built", "not_configured", "unavailable"]
|
||||
index_revision: GitCommitId | None = None
|
||||
index_schema_version: int | None = Field(default=None, ge=1)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Allowlisted operation catalog shared by transport adapters."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Final
|
||||
|
||||
|
||||
class Transport(StrEnum):
|
||||
HTTP = "http"
|
||||
MCP = "mcp"
|
||||
OWNER_MCP = "owner_mcp"
|
||||
STDIO = "stdio"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Operation:
|
||||
name: str
|
||||
capability: str
|
||||
transports: frozenset[Transport]
|
||||
mutates_knowledge: bool = False
|
||||
|
||||
|
||||
OPERATIONS: Final[tuple[Operation, ...]] = (
|
||||
Operation("health", "health:read", frozenset({Transport.HTTP})),
|
||||
Operation("diagnostics", "service:diagnostics", frozenset({Transport.HTTP, Transport.MCP})),
|
||||
Operation("browser_session_create", "identity:session", frozenset({Transport.HTTP})),
|
||||
Operation("browser_session_restore", "identity:session", frozenset({Transport.HTTP})),
|
||||
Operation("browser_session_revoke", "identity:session", frozenset({Transport.HTTP})),
|
||||
Operation("account_login", "identity:session", frozenset({Transport.HTTP})),
|
||||
Operation("account_current", "identity:session", frozenset({Transport.HTTP})),
|
||||
Operation("account_change_password", "identity:session", frozenset({Transport.HTTP})),
|
||||
Operation("account_list_agent_credentials", "identity:session", frozenset({Transport.HTTP})),
|
||||
Operation("account_create_agent_credential", "identity:session", frozenset({Transport.HTTP})),
|
||||
Operation("account_revoke_agent_credential", "identity:session", frozenset({Transport.HTTP})),
|
||||
Operation("admin_list_accounts", "credential:admin", frozenset({Transport.HTTP})),
|
||||
Operation("admin_create_account", "credential:admin", frozenset({Transport.HTTP})),
|
||||
Operation("admin_update_account", "credential:admin", frozenset({Transport.HTTP})),
|
||||
Operation("admin_disable_account", "credential:admin", frozenset({Transport.HTTP})),
|
||||
Operation("owner_session_create", "owner:authenticate", frozenset({Transport.HTTP})),
|
||||
Operation("admin_list_credentials", "credential:admin", frozenset({Transport.HTTP})),
|
||||
Operation("admin_create_credential", "credential:admin", frozenset({Transport.HTTP})),
|
||||
Operation("admin_update_credential", "credential:admin", frozenset({Transport.HTTP})),
|
||||
Operation("admin_revoke_credential", "credential:admin", frozenset({Transport.HTTP})),
|
||||
Operation("admin_list_events", "credential:admin", frozenset({Transport.HTTP})),
|
||||
Operation("admin_list_projects", "project:admin", frozenset({Transport.HTTP})),
|
||||
Operation("project_create", "project:admin", frozenset({Transport.HTTP})),
|
||||
Operation("project_update_content", "project:admin", frozenset({Transport.HTTP})),
|
||||
Operation("project_archive", "project:admin", frozenset({Transport.HTTP})),
|
||||
Operation("list_projects", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})),
|
||||
Operation("get_project_context", "knowledge:read", frozenset({Transport.MCP})),
|
||||
Operation("search", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})),
|
||||
Operation("list_sources", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})),
|
||||
Operation("get_node", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})),
|
||||
Operation("node_history", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})),
|
||||
Operation("get_source", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})),
|
||||
Operation("get_source_artifact", "knowledge:read", frozenset({Transport.HTTP})),
|
||||
Operation("get_source_location", "knowledge:read", frozenset({Transport.HTTP})),
|
||||
Operation("neighbors", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})),
|
||||
Operation("source_capture_url", "proposal:submit", frozenset({Transport.MCP})),
|
||||
Operation("source_capture_text", "proposal:submit", frozenset({Transport.MCP})),
|
||||
Operation("source_upload_begin", "proposal:submit", frozenset({Transport.MCP})),
|
||||
Operation("source_upload_status", "proposal:submit", frozenset({Transport.MCP})),
|
||||
Operation("source_upload_chunk", "proposal:submit", frozenset({Transport.MCP})),
|
||||
Operation("source_upload_finalize", "proposal:submit", frozenset({Transport.MCP})),
|
||||
Operation("source_extract", "proposal:submit", frozenset({Transport.MCP})),
|
||||
Operation("source_extraction_block", "proposal:submit", frozenset({Transport.MCP})),
|
||||
Operation("proposal_submit", "proposal:submit", frozenset({Transport.MCP}), True),
|
||||
Operation("proposal_evaluate", "proposal:evaluate", frozenset({Transport.MCP})),
|
||||
Operation("proposal_status", "proposal:read", frozenset({Transport.MCP})),
|
||||
Operation("proposal_withdraw", "proposal:withdraw", frozenset({Transport.MCP})),
|
||||
Operation("run_create", "run:write", frozenset({Transport.MCP})),
|
||||
Operation("run_checkpoint", "run:write", frozenset({Transport.MCP})),
|
||||
Operation("run_status", "run:read", frozenset({Transport.MCP})),
|
||||
Operation("owner_challenge_get", "owner:review", frozenset({Transport.OWNER_MCP})),
|
||||
Operation("owner_diff_get", "owner:review", frozenset({Transport.OWNER_MCP})),
|
||||
Operation("owner_decide", "owner:decide", frozenset({Transport.OWNER_MCP})),
|
||||
Operation("list_selected_files", "local:file:read", frozenset({Transport.STDIO})),
|
||||
Operation("upload_selected_file", "proposal:submit", frozenset({Transport.STDIO})),
|
||||
)
|
||||
|
||||
|
||||
def operations_for(transport: Transport) -> tuple[Operation, ...]:
|
||||
"""Return the explicit allowlist for a transport."""
|
||||
|
||||
return tuple(operation for operation in OPERATIONS if transport in operation.transports)
|
||||
|
||||
|
||||
def catalog_bytes() -> bytes:
|
||||
"""Return the canonical generated catalog bytes used by contract snapshots."""
|
||||
|
||||
payload = {
|
||||
"api_version": "v1",
|
||||
"schema_version": 1,
|
||||
"operations": [
|
||||
{
|
||||
"name": operation.name,
|
||||
"capability": operation.capability,
|
||||
"transports": sorted(transport.value for transport in operation.transports),
|
||||
"mutates_knowledge": operation.mutates_knowledge,
|
||||
}
|
||||
for operation in OPERATIONS
|
||||
],
|
||||
}
|
||||
return (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def catalog_digest() -> str:
|
||||
"""Hash the same bytes written by the schema exporter."""
|
||||
|
||||
return hashlib.sha256(catalog_bytes()).hexdigest()
|
||||
@@ -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