feat(records): define archive transfer provider contract

This commit is contained in:
2026-08-06 05:36:11 +02:00
parent 5d1287735e
commit f5949427cc
3 changed files with 275 additions and 7 deletions
+23
View File
@@ -11,6 +11,8 @@ revision.
- `records.filing` is supplied by the enabled Records module.
- `records.source.<module>` is supplied by an enabled source module, for
example `records.source.files` or `records.source.cases`.
- `records.archive.<provider>` is supplied by an enabled archive-transfer
adapter. Discovery does not imply conformance or current health.
Callers discover capabilities through the module registry. They must not
import optional source-module internals.
@@ -59,3 +61,24 @@ additional optional metadata remains backward compatible.
Provider-specific selection UI belongs to the source module. The generic
Records dialog remains a diagnostic/manual fallback for exact identifiers.
## Archive Transfer Boundary
`RecordTransferPackage` binds a stable package ID, record revision, provider
profile, canonical manifest, and manifest SHA-256. An archive provider exposes
`RecordArchiveProviderState` before dispatch and accepts only a
`RecordArchiveTransferRequest` for a declared healthy profile. Its receipt must
identify the same package and provider and return one bounded outcome:
`accepted`, `rejected`, or `outcome_unknown`.
An unknown outcome is never retry-safe. Callers must retain the intent and
reconcile it against the provider before another effect. Provider state also
declares authority mode, freshness, limitations, and whether the provider is a
simulation. Credentials, transport configuration, archive-specific package
schemas, and custody semantics remain provider-owned.
Records includes `records.archive.simulation` to prove package and receipt
handling. The simulation is explicitly non-conformant, transfers no custody,
and cannot be used as evidence of an archive handoff. A real provider requires
a selected target/profile, provider-specific recovery declaration, and target
test evidence.
+174 -6
View File
@@ -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",
]
+78 -1
View File
@@ -4,10 +4,16 @@ from datetime import UTC, datetime
import unittest
from govoplan_core.core.records import (
RecordArchiveProviderState,
RecordArchiveReceipt,
RecordArchiveTransferRequest,
RecordContractError,
RecordFilingRequest,
RecordSourceLocator,
RecordSourceReference,
RecordTransferPackage,
record_archive_capabilities,
record_archive_capability,
record_source_capabilities,
record_source_capability,
)
@@ -15,7 +21,12 @@ from govoplan_core.core.records import (
class _Registry:
def capability_names(self) -> tuple[str, ...]:
return ("mail.delivery", "records.source.cases", "records.source.files")
return (
"mail.delivery",
"records.archive.simulation",
"records.source.cases",
"records.source.files",
)
class RecordsContractTests(unittest.TestCase):
@@ -84,6 +95,72 @@ class RecordsContractTests(unittest.TestCase):
record_source_capabilities(_Registry()),
)
def test_archive_contract_is_digest_bound_and_discoverable(self) -> None:
package = RecordTransferPackage(
tenant_id="tenant-1",
package_id="package-1",
record_id="record-1",
record_revision=3,
profile="govoplan-simulation-v1",
manifest_sha256="a" * 64,
manifest={"record_id": "record-1", "items": []},
)
request = RecordArchiveTransferRequest(
package=package,
purpose="archive appraisal",
idempotency_key="transfer-1",
)
state = RecordArchiveProviderState(
provider_id="simulation",
label="Archive simulation",
profiles=("govoplan-simulation-v1",),
authority_modes=("linked_reference",),
healthy=True,
checked_at=datetime(2026, 8, 6, tzinfo=UTC),
simulated=True,
)
self.assertEqual("package-1", request.package.package_id)
self.assertTrue(state.simulated)
self.assertEqual(
"records.archive.simulation",
record_archive_capability("simulation"),
)
self.assertEqual(
("records.archive.simulation",),
record_archive_capabilities(_Registry()),
)
def test_unknown_archive_outcome_cannot_be_retry_safe(self) -> None:
with self.assertRaisesRegex(RecordContractError, "retry-safe"):
RecordArchiveReceipt(
provider_id="archive-1",
package_id="package-1",
outcome="outcome_unknown",
observed_at=datetime(2026, 8, 6, tzinfo=UTC),
receipt_sha256="b" * 64,
retry_safe=True,
)
def test_archive_provider_runtime_values_are_validated(self) -> None:
with self.assertRaisesRegex(RecordContractError, "invalid authority mode"):
RecordArchiveProviderState(
provider_id="archive",
label="Archive",
profiles=("profile",),
authority_modes=("untrusted",), # type: ignore[arg-type]
healthy=True,
checked_at=datetime.now(UTC),
)
with self.assertRaisesRegex(RecordContractError, "outcome is invalid"):
RecordArchiveReceipt(
provider_id="archive",
package_id="package-1",
outcome="maybe", # type: ignore[arg-type]
observed_at=datetime.now(UTC),
receipt_sha256="a" * 64,
)
if __name__ == "__main__":
unittest.main()