feat(records): define archive transfer provider contract
This commit is contained in:
@@ -8,6 +8,7 @@ from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
CAPABILITY_RECORDS_FILING = "records.filing"
|
||||
CAPABILITY_RECORD_SOURCE_PREFIX = "records.source."
|
||||
CAPABILITY_RECORD_ARCHIVE_PREFIX = "records.archive."
|
||||
|
||||
RecordSourceAuthority = Literal[
|
||||
"native_authoritative",
|
||||
@@ -17,6 +18,17 @@ RecordSourceAuthority = Literal[
|
||||
"governance_overlay",
|
||||
"linked_reference",
|
||||
]
|
||||
RecordArchiveOutcome = Literal["accepted", "rejected", "outcome_unknown"]
|
||||
|
||||
_RECORD_SOURCE_AUTHORITIES = {
|
||||
"native_authoritative",
|
||||
"external_authoritative",
|
||||
"external_mirror",
|
||||
"governed_sync",
|
||||
"governance_overlay",
|
||||
"linked_reference",
|
||||
}
|
||||
_RECORD_ARCHIVE_OUTCOMES = {"accepted", "rejected", "outcome_unknown"}
|
||||
|
||||
|
||||
class RecordContractError(ValueError):
|
||||
@@ -129,6 +141,109 @@ class RecordFilingResult:
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordTransferPackage:
|
||||
"""Exact, digest-bound package prepared by Records for one provider profile."""
|
||||
|
||||
tenant_id: str
|
||||
package_id: str
|
||||
record_id: str
|
||||
record_revision: int
|
||||
profile: str
|
||||
manifest_sha256: str
|
||||
manifest: Mapping[str, object]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_text_fields(
|
||||
self,
|
||||
"tenant_id",
|
||||
"package_id",
|
||||
"record_id",
|
||||
"profile",
|
||||
"manifest_sha256",
|
||||
)
|
||||
if self.record_revision < 1:
|
||||
raise RecordContractError("Record transfer revisions must be positive.")
|
||||
_require_sha256(self.manifest_sha256, "Record transfer manifest")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordArchiveProviderState:
|
||||
provider_id: str
|
||||
label: str
|
||||
profiles: tuple[str, ...]
|
||||
authority_modes: tuple[RecordSourceAuthority, ...]
|
||||
healthy: bool
|
||||
checked_at: datetime
|
||||
last_success_at: datetime | None = None
|
||||
freshness_seconds: int | None = None
|
||||
limitations: tuple[str, ...] = ()
|
||||
simulated: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_text_fields(self, "provider_id", "label")
|
||||
if not self.profiles or any(not item.strip() for item in self.profiles):
|
||||
raise RecordContractError(
|
||||
"Record archive providers require at least one profile."
|
||||
)
|
||||
if not self.authority_modes:
|
||||
raise RecordContractError(
|
||||
"Record archive providers require an authority mode."
|
||||
)
|
||||
if any(mode not in _RECORD_SOURCE_AUTHORITIES for mode in self.authority_modes):
|
||||
raise RecordContractError(
|
||||
"Record archive providers declared an invalid authority mode."
|
||||
)
|
||||
if self.freshness_seconds is not None and self.freshness_seconds < 0:
|
||||
raise RecordContractError(
|
||||
"Record archive provider freshness cannot be negative."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordArchiveTransferRequest:
|
||||
package: RecordTransferPackage
|
||||
purpose: str
|
||||
idempotency_key: str
|
||||
institutional_context: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_text_fields(self, "purpose", "idempotency_key")
|
||||
if len(self.purpose) > 255 or len(self.idempotency_key) > 255:
|
||||
raise RecordContractError(
|
||||
"Record archive purpose or idempotency key is too long."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordArchiveReceipt:
|
||||
provider_id: str
|
||||
package_id: str
|
||||
outcome: RecordArchiveOutcome
|
||||
observed_at: datetime
|
||||
receipt_sha256: str
|
||||
external_reference: str | None = None
|
||||
retry_safe: bool = False
|
||||
simulated: bool = False
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_text_fields(
|
||||
self,
|
||||
"provider_id",
|
||||
"package_id",
|
||||
"outcome",
|
||||
"receipt_sha256",
|
||||
)
|
||||
_require_sha256(self.receipt_sha256, "Record archive receipt")
|
||||
if self.outcome not in _RECORD_ARCHIVE_OUTCOMES:
|
||||
raise RecordContractError("Record archive receipt outcome is invalid.")
|
||||
if self.outcome == "outcome_unknown" and self.retry_safe:
|
||||
raise RecordContractError(
|
||||
"Unknown archive outcomes cannot be declared retry-safe."
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RecordSourceProvider(Protocol):
|
||||
provider_id: str
|
||||
@@ -157,16 +272,32 @@ class RecordFilingService(Protocol):
|
||||
) -> RecordFilingResult: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RecordArchiveProvider(Protocol):
|
||||
provider_id: str
|
||||
|
||||
def state(self) -> RecordArchiveProviderState: ...
|
||||
|
||||
def dispatch(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: RecordArchiveTransferRequest,
|
||||
) -> RecordArchiveReceipt:
|
||||
"""Dispatch one prepared package without retrying an unknown outcome."""
|
||||
|
||||
|
||||
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.")
|
||||
normalized = _capability_suffix(source_module, "source module")
|
||||
return f"{CAPABILITY_RECORD_SOURCE_PREFIX}{normalized}"
|
||||
|
||||
|
||||
def record_archive_capability(provider_id: str) -> str:
|
||||
normalized = _capability_suffix(provider_id, "archive provider")
|
||||
return f"{CAPABILITY_RECORD_ARCHIVE_PREFIX}{normalized}"
|
||||
|
||||
|
||||
def record_source_capabilities(registry: object | None) -> tuple[str, ...]:
|
||||
if registry is None or not hasattr(registry, "capability_names"):
|
||||
return ()
|
||||
@@ -177,6 +308,16 @@ def record_source_capabilities(registry: object | None) -> tuple[str, ...]:
|
||||
)
|
||||
|
||||
|
||||
def record_archive_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_ARCHIVE_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():
|
||||
@@ -185,9 +326,33 @@ def _require_text_fields(value: object, *field_names: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _capability_suffix(value: str, label: str) -> str:
|
||||
normalized = value.strip().lower()
|
||||
if not normalized or any(
|
||||
character not in "abcdefghijklmnopqrstuvwxyz0123456789_-"
|
||||
for character in normalized
|
||||
):
|
||||
raise RecordContractError(f"Record {label} identifiers are invalid.")
|
||||
return normalized
|
||||
|
||||
|
||||
def _require_sha256(value: str, label: str) -> None:
|
||||
digest = value.removeprefix("sha256:")
|
||||
if len(digest) != 64 or any(
|
||||
character not in "0123456789abcdefABCDEF" for character in digest
|
||||
):
|
||||
raise RecordContractError(f"{label} SHA-256 must be hexadecimal.")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_RECORD_ARCHIVE_PREFIX",
|
||||
"CAPABILITY_RECORDS_FILING",
|
||||
"CAPABILITY_RECORD_SOURCE_PREFIX",
|
||||
"RecordArchiveOutcome",
|
||||
"RecordArchiveProvider",
|
||||
"RecordArchiveProviderState",
|
||||
"RecordArchiveReceipt",
|
||||
"RecordArchiveTransferRequest",
|
||||
"RecordContractError",
|
||||
"RecordFilingRequest",
|
||||
"RecordFilingResult",
|
||||
@@ -196,6 +361,9 @@ __all__ = [
|
||||
"RecordSourceLocator",
|
||||
"RecordSourceProvider",
|
||||
"RecordSourceReference",
|
||||
"RecordTransferPackage",
|
||||
"record_archive_capabilities",
|
||||
"record_archive_capability",
|
||||
"record_source_capabilities",
|
||||
"record_source_capability",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user