feat(core): add provider-neutral records filing contract
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
CAPABILITY_RECORDS_FILING = "records.filing"
|
||||
CAPABILITY_RECORD_SOURCE_PREFIX = "records.source."
|
||||
|
||||
RecordSourceAuthority = Literal[
|
||||
"native_authoritative",
|
||||
"external_authoritative",
|
||||
"external_mirror",
|
||||
"governed_sync",
|
||||
"governance_overlay",
|
||||
"linked_reference",
|
||||
]
|
||||
|
||||
|
||||
class RecordContractError(ValueError):
|
||||
"""Stable error for provider-neutral record filing operations."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordSourceLocator:
|
||||
"""Exact source revision requested for filing into a record."""
|
||||
|
||||
tenant_id: str
|
||||
source_module: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
source_revision: str
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_text_fields(
|
||||
self,
|
||||
"tenant_id",
|
||||
"source_module",
|
||||
"resource_type",
|
||||
"resource_id",
|
||||
"source_revision",
|
||||
)
|
||||
if len(self.resource_id) > 500 or len(self.source_revision) > 255:
|
||||
raise RecordContractError("Record source identity is too long.")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordSourceReference:
|
||||
"""Provider-resolved immutable source metadata safe to preserve in Records."""
|
||||
|
||||
locator: RecordSourceLocator
|
||||
label: str
|
||||
authority_mode: RecordSourceAuthority = "linked_reference"
|
||||
content_sha256: str | None = None
|
||||
content_type: str | None = None
|
||||
size_bytes: int | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_to: datetime | None = None
|
||||
recorded_at: datetime | None = None
|
||||
launch_url: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.label.strip():
|
||||
raise RecordContractError("Record source references require a label.")
|
||||
if len(self.label) > 500:
|
||||
raise RecordContractError(
|
||||
"Record source labels are limited to 500 characters."
|
||||
)
|
||||
if self.size_bytes is not None and self.size_bytes < 0:
|
||||
raise RecordContractError("Record source sizes cannot be negative.")
|
||||
if self.content_sha256 is not None:
|
||||
digest = self.content_sha256.removeprefix("sha256:")
|
||||
if len(digest) != 64 or any(
|
||||
character not in "0123456789abcdefABCDEF" for character in digest
|
||||
):
|
||||
raise RecordContractError(
|
||||
"Record source SHA-256 digests must be hexadecimal."
|
||||
)
|
||||
if self.valid_from and self.valid_to and self.valid_to <= self.valid_from:
|
||||
raise RecordContractError(
|
||||
"Record source valid_to must be after valid_from."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordFilingRequest:
|
||||
tenant_id: str
|
||||
record_id: str
|
||||
source: RecordSourceLocator
|
||||
purpose: str
|
||||
filing_reason: str
|
||||
idempotency_key: str
|
||||
volume_id: str | None = None
|
||||
relationship: str = "contains"
|
||||
institutional_context: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_text_fields(
|
||||
self,
|
||||
"tenant_id",
|
||||
"record_id",
|
||||
"purpose",
|
||||
"filing_reason",
|
||||
"idempotency_key",
|
||||
"relationship",
|
||||
)
|
||||
if self.source.tenant_id != self.tenant_id:
|
||||
raise RecordContractError("Record filing cannot cross tenants.")
|
||||
if len(self.purpose) > 255 or len(self.filing_reason) > 2_000:
|
||||
raise RecordContractError("Record filing purpose or reason is too long.")
|
||||
if len(self.idempotency_key) > 255:
|
||||
raise RecordContractError(
|
||||
"Record filing idempotency keys are limited to 255 characters."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordFilingResult:
|
||||
record_id: str
|
||||
item_id: str
|
||||
sequence: int
|
||||
source: RecordSourceReference
|
||||
filed_at: datetime
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RecordSourceProvider(Protocol):
|
||||
provider_id: str
|
||||
|
||||
def resource_types(self) -> Sequence[str]: ...
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
locator: RecordSourceLocator,
|
||||
purpose: str,
|
||||
) -> RecordSourceReference:
|
||||
"""Resolve one currently authorized, exact source revision."""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RecordFilingService(Protocol):
|
||||
def file(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: RecordFilingRequest,
|
||||
) -> RecordFilingResult: ...
|
||||
|
||||
|
||||
def record_source_capability(source_module: str) -> str:
|
||||
normalized = source_module.strip().lower()
|
||||
if not normalized or any(
|
||||
character not in "abcdefghijklmnopqrstuvwxyz0123456789_-"
|
||||
for character in normalized
|
||||
):
|
||||
raise RecordContractError("Record source module identifiers are invalid.")
|
||||
return f"{CAPABILITY_RECORD_SOURCE_PREFIX}{normalized}"
|
||||
|
||||
|
||||
def record_source_capabilities(registry: object | None) -> tuple[str, ...]:
|
||||
if registry is None or not hasattr(registry, "capability_names"):
|
||||
return ()
|
||||
return tuple(
|
||||
name
|
||||
for name in registry.capability_names()
|
||||
if str(name).startswith(CAPABILITY_RECORD_SOURCE_PREFIX)
|
||||
)
|
||||
|
||||
|
||||
def _require_text_fields(value: object, *field_names: str) -> None:
|
||||
for field_name in field_names:
|
||||
if not str(getattr(value, field_name, "") or "").strip():
|
||||
raise RecordContractError(
|
||||
f"Record contract field {field_name} is required."
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_RECORDS_FILING",
|
||||
"CAPABILITY_RECORD_SOURCE_PREFIX",
|
||||
"RecordContractError",
|
||||
"RecordFilingRequest",
|
||||
"RecordFilingResult",
|
||||
"RecordFilingService",
|
||||
"RecordSourceAuthority",
|
||||
"RecordSourceLocator",
|
||||
"RecordSourceProvider",
|
||||
"RecordSourceReference",
|
||||
"record_source_capabilities",
|
||||
"record_source_capability",
|
||||
]
|
||||
Reference in New Issue
Block a user