"""Versioned public records shared by GEWU transports and adapters.""" from __future__ import annotations import ipaddress import re import unicodedata from datetime import date, datetime from enum import StrEnum from typing import Annotated, Literal from urllib.parse import urlsplit from pydantic import ( BaseModel, ConfigDict, Field, FiniteFloat, StringConstraints, field_validator, model_validator, ) SCHEMA_VERSION = 1 ResearchLens = Literal[ "technical", "conceptual", "cultural-historical", "financial-market", ] FacetName = Literal["tags", "audience", "use_for", "domain"] FacetLabelStatus = Literal["active", "merged", "retired"] DEFAULT_RESEARCH_LENSES: tuple[ResearchLens, ...] = ( "technical", "conceptual", "cultural-historical", "financial-market", ) 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})$")] def _normalized_facet_label(value: str) -> str: return " ".join(unicodedata.normalize("NFC", value).casefold().split()) def _clean_facet_label(value: str) -> str: cleaned = " ".join(unicodedata.normalize("NFC", value).split()) if not cleaned: raise ValueError("Facet labels must contain non-whitespace text") return cleaned 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 ProjectFacetLabel(StrictRecord): label_id: RecordId facet: FacetName canonical_label: SearchFacetValue definition: str = Field(min_length=1, max_length=500) aliases: tuple[SearchFacetValue, ...] = Field(default=(), max_length=20) status: FacetLabelStatus = "active" merged_into: RecordId | None = None origin_review_id: RecordId | None = None jev_model: str | None = Field(default=None, max_length=100) jev_fit_probability: FiniteFloat | None = Field(default=None, ge=0, le=1) jev_new_probability: FiniteFloat | None = Field(default=None, ge=0, le=1) created_at: datetime @field_validator("canonical_label") @classmethod def normalize_canonical_label(cls, value: str) -> str: return _clean_facet_label(value) @field_validator("aliases") @classmethod def normalize_aliases(cls, values: tuple[str, ...]) -> tuple[str, ...]: return tuple(_clean_facet_label(value) for value in values) @model_validator(mode="after") def validate_label_lifecycle(self) -> ProjectFacetLabel: if self.status == "merged" and self.merged_into is None: raise ValueError("Merged facet labels require a canonical target") if self.status != "merged" and self.merged_into is not None: raise ValueError("Only merged facet labels may have a merge target") names = (self.canonical_label, *self.aliases) if len({_normalized_facet_label(name) for name in names}) != len(names): raise ValueError("Facet label and aliases must be unique") offset = self.created_at.utcoffset() if offset is None or offset.total_seconds() != 0: raise ValueError("Facet label timestamp must be timezone-aware UTC") return self 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) research_lenses: tuple[ResearchLens, ...] = DEFAULT_RESEARCH_LENSES 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) facet_labels: tuple[ProjectFacetLabel, ...] = Field(default=(), max_length=2000) 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 IngestGuidance(StrictRecord): guidance_id: RecordId lens: ResearchLens rule: str = Field(min_length=1, max_length=1000) created_at: datetime @field_validator("created_at") @classmethod def guidance_timestamp_is_utc(cls, value: datetime) -> datetime: offset = value.utcoffset() if offset is None or offset.total_seconds() != 0: raise ValueError("guidance timestamp must be timezone-aware UTC") 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) research_lenses: tuple[ResearchLens, ...] = DEFAULT_RESEARCH_LENSES watch_list: tuple[str, ...] = Field(default=(), max_length=100) taxonomy_extensions: dict[str, tuple[str, ...]] = Field(default_factory=dict, max_length=100) facet_labels: tuple[ProjectFacetLabel, ...] = Field(default=(), max_length=2000) ingest_guidance: tuple[IngestGuidance, ...] = Field(default=(), max_length=100) class ProjectContextResponse(StrictRecord): lens: ProjectLens class JevFacetScore(StrictRecord): facet: FacetName value: SearchFacetValue probability: FiniteFloat = Field(ge=0, le=1) class JevFacetOptionScore(StrictRecord): label_id: RecordId | None = None label: SearchFacetValue probability: FiniteFloat = Field(ge=0, le=1) class JevFacetResolution(StrictRecord): facet: FacetName value: SearchFacetValue fit_probability: FiniteFloat = Field(ge=0, le=1) selected_label_id: RecordId | None = None selected_label: SearchFacetValue | None = None selected_new_label: bool = False new_probability: FiniteFloat = Field(ge=0, le=1) choice_confidence: FiniteFloat = Field(ge=0, le=1) option_probabilities: tuple[JevFacetOptionScore, ...] = Field(default=(), max_length=6) rank: int = Field(ge=1, le=20) added_to_project: bool = False class FacetLabelProposal(StrictRecord): facet: FacetName value: SearchFacetValue definition: str = Field(min_length=1, max_length=500) @field_validator("value") @classmethod def normalize_value(cls, value: str) -> str: return _clean_facet_label(value) @field_validator("definition") @classmethod def normalize_definition(cls, value: str) -> str: definition = value.strip() if not definition: raise ValueError("Facet label definitions must contain non-whitespace text") return definition class IngestReviewItem(StrictRecord): review_id: RecordId project_id: RecordId run_id: RecordId source_revision_id: RecordId extraction_id: Sha256 locator: Locator excerpt: str = Field(min_length=1, max_length=4000) lens: ResearchLens summary: str = Field(min_length=1, max_length=2000) relevance_rationale: str = Field(min_length=1, max_length=2000) relevance: Literal["borderline", "low"] = "borderline" jev_facet_scores: tuple[JevFacetScore, ...] = Field(default=(), max_length=20) jev_facet_resolutions: tuple[JevFacetResolution, ...] = Field(default=(), max_length=20) jev_relation_probability: FiniteFloat | None = Field(default=None, ge=0, le=1) jev_lens: ResearchLens | None = None jev_lens_confidence: FiniteFloat | None = Field(default=None, ge=0, le=1) jev_model: str | None = Field(default=None, max_length=100) status: Literal["needs-user-review", "included", "excluded", "deferred"] = "needs-user-review" submitted_by: RecordId created_at: datetime @field_validator("created_at") @classmethod def review_timestamp_is_utc(cls, value: datetime) -> datetime: offset = value.utcoffset() if offset is None or offset.total_seconds() != 0: raise ValueError("review timestamp must be timezone-aware UTC") return value class IngestReviewCreate(StrictRecord): review_id: RecordId project_id: RecordId run_id: RecordId source_revision_id: RecordId extraction_id: Sha256 locator: Locator excerpt: str = Field(min_length=1, max_length=4000) lens: ResearchLens summary: str = Field(min_length=1, max_length=2000) relevance_rationale: str = Field(min_length=1, max_length=2000) relevance: Literal["borderline", "low"] = "borderline" candidate_tags: tuple[SearchFacetValue, ...] = Field(default=(), max_length=5) candidate_audience: tuple[SearchFacetValue, ...] = Field(default=(), max_length=3) candidate_use_for: tuple[SearchFacetValue, ...] = Field(default=(), max_length=3) candidate_domain: tuple[SearchFacetValue, ...] = Field(default=(), max_length=3) candidate_facet_labels: tuple[FacetLabelProposal, ...] = Field(default=(), max_length=14) @model_validator(mode="after") def limit_total_facet_candidates(self) -> IngestReviewCreate: values = ( *self.candidate_tags, *self.candidate_audience, *self.candidate_use_for, *self.candidate_domain, ) proposed = (*values, *(proposal.value for proposal in self.candidate_facet_labels)) if len(proposed) > 14: raise ValueError("At most 14 facet candidates may be assessed") facet_limits = {"tags": 5, "audience": 3, "use_for": 3, "domain": 3} facet_counts = {facet: 0 for facet in facet_limits} for facet, candidates in ( ("tags", self.candidate_tags), ("audience", self.candidate_audience), ("use_for", self.candidate_use_for), ("domain", self.candidate_domain), ): facet_counts[facet] += len(candidates) for proposal in self.candidate_facet_labels: facet_counts[proposal.facet] += 1 if any(facet_counts[facet] > limit for facet, limit in facet_limits.items()): raise ValueError("Facet candidates exceed the per-facet limits (tags: 5; others: 3)") keyed = { (facet, _normalized_facet_label(value)) for facet, candidates in ( ("tags", self.candidate_tags), ("audience", self.candidate_audience), ("use_for", self.candidate_use_for), ("domain", self.candidate_domain), ) for value in candidates } keyed.update( (proposal.facet, _normalized_facet_label(proposal.value)) for proposal in self.candidate_facet_labels ) if len(keyed) != len(proposed): raise ValueError("Facet candidates must be unique within each facet") return self def facet_proposals(self) -> tuple[FacetLabelProposal, ...]: proposals: list[FacetLabelProposal] = list(self.candidate_facet_labels) fields: tuple[tuple[FacetName, tuple[SearchFacetValue, ...]], ...] = ( ("tags", self.candidate_tags), ("audience", self.candidate_audience), ("use_for", self.candidate_use_for), ("domain", self.candidate_domain), ) for facet, values in fields: proposals.extend( FacetLabelProposal(facet=facet, value=value, definition=self.summary[:500]) for value in values ) return tuple(proposals) class IngestReviewDecision(StrictRecord): decision_id: RecordId review_id: RecordId decision: Literal["include", "exclude", "defer"] rationale: str = Field(min_length=1, max_length=2000) save_as_guidance: bool = False guidance_rule: str | None = Field(default=None, max_length=1000) @model_validator(mode="after") def guidance_requires_explicit_rule(self) -> IngestReviewDecision: if self.save_as_guidance and not (self.guidance_rule or "").strip(): raise ValueError("saving project guidance requires a non-empty guidance rule") if not self.save_as_guidance and self.guidance_rule is not None: raise ValueError("guidance_rule requires explicit save_as_guidance") return self 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 OnlineEvidence(StrictRecord): """A short online excerpt with its relationship to a claim and verification URL.""" url: str = Field(min_length=1, max_length=4000) text: str = Field(min_length=1, max_length=8000) relation: Literal["supports", "contradicts", "context", "inconclusive"] context: str = Field(min_length=1, max_length=4000) @field_validator("url") @classmethod def public_http_url(cls, value: str) -> str: parsed = urlsplit(value) hostname = parsed.hostname if ( parsed.scheme not in {"http", "https"} or not hostname or parsed.username or parsed.password ): raise ValueError("online evidence requires an HTTP(S) URL without embedded credentials") if hostname.lower() == "localhost" or hostname.lower().endswith((".localhost", ".local")): raise ValueError("web references must use a public hostname") try: address = ipaddress.ip_address(hostname) except ValueError: address = None if address is not None and not address.is_global: raise ValueError("web references must use a public hostname") return value 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 online_evidence: tuple[OnlineEvidence, ...] = Field(default=(), max_length=20) @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", "update_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, "update_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.operation == "update_node": if self.target_id is None or self.node is None or self.target_id != self.node.node_id: raise ValueError("update_node target_id must match the stable node ID") elif self.target_id is not None: raise ValueError("target_id is only valid for update_node or 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) include_evidence: bool = True @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 KnowledgeBrowseRequest(StrictRecord): """Deterministic, paged project browsing independent of retrieval search.""" scope: Scope query: str = Field(default="", max_length=1000) 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 order: Literal[ "title_asc", "title_desc", "date_newest", "date_oldest", "relevance" ] = "title_asc" 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) @model_validator(mode="after") def validate_browse(self) -> KnowledgeBrowseRequest: if ( self.as_of_from is not None and self.as_of_to is not None and self.as_of_from > self.as_of_to ): raise ValueError("as_of_from must not be later than as_of_to") if self.order == "relevance" and not self.query: raise ValueError("relevance ordering requires a search query") return self @field_validator("node_types") @classmethod def browse_node_types_are_unique(cls, value: tuple[NodeType, ...]) -> tuple[NodeType, ...]: if len(value) != len(set(value)): raise ValueError("browse node types must be unique") return value @field_validator("confidences") @classmethod def browse_confidences_are_unique(cls, value: tuple[Confidence, ...]) -> tuple[Confidence, ...]: if len(value) != len(set(value)): raise ValueError("browse confidences must be unique") return value @field_validator("tags", "audience", "use_for", "domain") @classmethod def browse_facets_are_unique_and_trimmed(cls, value: tuple[str, ...]) -> tuple[str, ...]: if any(item != item.strip() for item in value) or len(value) != len(set(value)): raise ValueError("browse facet values must be unique and trimmed") return value class KnowledgeBrowseEnvelope(StrictRecord): """A bounded browse page with scope-safe project metadata.""" api_version: Literal["v1"] = "v1" request_id: RecordId resolved_scope: Scope nodes: tuple[KnowledgeNode, ...] project_node_count: int = Field(ge=0) project_source_count: int = Field(ge=0) shared_node_count: int = Field(ge=0) shared_source_count: int = Field(ge=0) filtered_node_count: int = Field(ge=0) facets: dict[str, dict[str, int]] canonical_revision: GitCommitId | None index_revision: GitCommitId | None warnings: tuple[Finding, ...] = () next_cursor: str | None = None class KnowledgeGraphRequest(StrictRecord): scope: Scope max_nodes: int = Field(default=200, ge=1, le=200) max_edges: int = Field(default=500, ge=1, le=500) center_node_id: RecordId | None = None class KnowledgeGraphEnvelope(StrictRecord): api_version: Literal["v1"] = "v1" request_id: RecordId resolved_scope: Scope nodes: tuple[KnowledgeNode, ...] edges: tuple[TypedEdge, ...] truncated: bool center_node_id: RecordId | None = None canonical_revision: GitCommitId | None index_revision: GitCommitId | None warnings: tuple[Finding, ...] = () class SourceRelatedNode(StrictRecord): node: KnowledgeNode relation_basis: Literal["citation", "edge", "both"] class SourceRelatedNodesEnvelope(StrictRecord): api_version: Literal["v1"] = "v1" request_id: RecordId resolved_scope: Scope source_revision_id: RecordId nodes: tuple[SourceRelatedNode, ...] total: int = Field(ge=0) canonical_revision: GitCommitId | None index_revision: GitCommitId | None warnings: tuple[Finding, ...] = () next_cursor: str | None = None 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], ...] filtered_count: int | None = Field(default=None, ge=0) 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)