feat(records): govern lifecycle recovery and transfer evidence
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from govoplan_core.core.records import (
|
||||
RecordArchiveProviderState,
|
||||
RecordArchiveReceipt,
|
||||
RecordArchiveTransferRequest,
|
||||
RecordContractError,
|
||||
)
|
||||
|
||||
|
||||
SIMULATION_PROVIDER_ID = "simulation"
|
||||
SIMULATION_PROFILE = "govoplan-simulation-v1"
|
||||
|
||||
|
||||
class SimulatedRecordArchiveProvider:
|
||||
"""Exercise the transfer boundary without claiming archival custody."""
|
||||
|
||||
provider_id = SIMULATION_PROVIDER_ID
|
||||
|
||||
def state(self) -> RecordArchiveProviderState:
|
||||
checked_at = datetime.now(UTC)
|
||||
return RecordArchiveProviderState(
|
||||
provider_id=self.provider_id,
|
||||
label="Records transfer simulation",
|
||||
profiles=(SIMULATION_PROFILE,),
|
||||
authority_modes=("linked_reference",),
|
||||
healthy=True,
|
||||
checked_at=checked_at,
|
||||
last_success_at=checked_at,
|
||||
freshness_seconds=0,
|
||||
limitations=(
|
||||
"Simulation validates package and receipt handling but does not transfer custody.",
|
||||
"It is not an xDOMEA or archival conformance profile.",
|
||||
),
|
||||
simulated=True,
|
||||
)
|
||||
|
||||
def dispatch(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: RecordArchiveTransferRequest,
|
||||
) -> RecordArchiveReceipt:
|
||||
del session, principal
|
||||
if request.package.profile != SIMULATION_PROFILE:
|
||||
raise RecordContractError(
|
||||
"The simulation provider only accepts its declared profile."
|
||||
)
|
||||
observed_at = datetime.now(UTC)
|
||||
receipt_payload = {
|
||||
"provider_id": self.provider_id,
|
||||
"package_id": request.package.package_id,
|
||||
"manifest_sha256": request.package.manifest_sha256,
|
||||
"profile": request.package.profile,
|
||||
"outcome": "accepted",
|
||||
"simulated": True,
|
||||
}
|
||||
receipt_sha256 = hashlib.sha256(
|
||||
json.dumps(
|
||||
receipt_payload,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
return RecordArchiveReceipt(
|
||||
provider_id=self.provider_id,
|
||||
package_id=request.package.package_id,
|
||||
outcome="accepted",
|
||||
observed_at=observed_at,
|
||||
receipt_sha256=receipt_sha256,
|
||||
external_reference=f"simulation:{request.package.package_id}",
|
||||
retry_safe=True,
|
||||
simulated=True,
|
||||
metadata={
|
||||
"manifest_sha256": request.package.manifest_sha256,
|
||||
"profile": request.package.profile,
|
||||
"custody_transferred": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SIMULATION_PROFILE",
|
||||
"SIMULATION_PROVIDER_ID",
|
||||
"SimulatedRecordArchiveProvider",
|
||||
]
|
||||
@@ -1,19 +1,25 @@
|
||||
from govoplan_records.backend.db.models import (
|
||||
RecordChronologyEntry,
|
||||
RecordClassRevision,
|
||||
RecordDispositionRevision,
|
||||
RecordFilePlanRevision,
|
||||
RecordHoldRevision,
|
||||
RecordIdentity,
|
||||
RecordItem,
|
||||
RecordRevision,
|
||||
RecordTransferPackageRevision,
|
||||
RecordVolumeRevision,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"RecordChronologyEntry",
|
||||
"RecordClassRevision",
|
||||
"RecordDispositionRevision",
|
||||
"RecordFilePlanRevision",
|
||||
"RecordHoldRevision",
|
||||
"RecordIdentity",
|
||||
"RecordItem",
|
||||
"RecordRevision",
|
||||
"RecordTransferPackageRevision",
|
||||
"RecordVolumeRevision",
|
||||
]
|
||||
|
||||
@@ -240,6 +240,24 @@ class RecordRevision(Base, TimestampMixin):
|
||||
changed_by: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
closed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
retention_started_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
retention_due_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
retention_rule: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
appraisal_state: Mapped[str | None] = mapped_column(
|
||||
String(40), nullable=True, index=True
|
||||
)
|
||||
appraisal: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
@@ -387,12 +405,197 @@ class RecordChronologyEntry(Base, TimestampMixin):
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class RecordHoldRevision(Base, TimestampMixin):
|
||||
__tablename__ = "record_hold_revisions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "hold_id", "revision", name="uq_record_hold_revision"
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id", "idempotency_key", name="uq_record_hold_idempotency"
|
||||
),
|
||||
Index("ix_record_hold_current", "tenant_id", "hold_id", "superseded_at"),
|
||||
Index("ix_record_hold_record", "tenant_id", "record_id", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
hold_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
record_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
previous_revision_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("record_hold_revisions.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
authority: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
scope: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
effective_from: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
effective_to: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
released_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
policy_refs: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
institutional_context: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
recorded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
superseded_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
changed_by: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
|
||||
class RecordDispositionRevision(Base, TimestampMixin):
|
||||
__tablename__ = "record_disposition_revisions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"disposition_id",
|
||||
"revision",
|
||||
name="uq_record_disposition_revision",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id", "idempotency_key", name="uq_record_disposition_idempotency"
|
||||
),
|
||||
Index(
|
||||
"ix_record_disposition_current",
|
||||
"tenant_id",
|
||||
"disposition_id",
|
||||
"superseded_at",
|
||||
),
|
||||
Index("ix_record_disposition_record", "tenant_id", "record_id", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
disposition_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
record_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
previous_revision_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("record_disposition_revisions.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
action: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
status: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
subject_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
subject_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
consequence_preview: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
policy_refs: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
approval_request_id: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
proposed_by: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
reviewed_by: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
institutional_context: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
recorded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
superseded_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
|
||||
class RecordTransferPackageRevision(Base, TimestampMixin):
|
||||
__tablename__ = "record_transfer_package_revisions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"package_id",
|
||||
"revision",
|
||||
name="uq_record_transfer_package_revision",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_record_transfer_package_idempotency",
|
||||
),
|
||||
Index(
|
||||
"ix_record_transfer_package_current",
|
||||
"tenant_id",
|
||||
"package_id",
|
||||
"superseded_at",
|
||||
),
|
||||
Index("ix_record_transfer_package_record", "tenant_id", "record_id", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
package_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
record_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
disposition_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
previous_revision_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("record_transfer_package_revisions.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
record_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
provider_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
profile: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
authority_mode: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
manifest: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
manifest_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
receipt: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
receipt_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
external_reference: Mapped[str | None] = mapped_column(String(1500), nullable=True)
|
||||
recovery_operation_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True, index=True
|
||||
)
|
||||
simulated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
institutional_context: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
recorded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
superseded_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
changed_by: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RecordChronologyEntry",
|
||||
"RecordClassRevision",
|
||||
"RecordFilePlanRevision",
|
||||
"RecordHoldRevision",
|
||||
"RecordIdentity",
|
||||
"RecordItem",
|
||||
"RecordDispositionRevision",
|
||||
"RecordRevision",
|
||||
"RecordTransferPackageRevision",
|
||||
"RecordVolumeRevision",
|
||||
]
|
||||
|
||||
@@ -29,13 +29,20 @@ from govoplan_core.core.modules import (
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||
from govoplan_core.core.records import CAPABILITY_RECORDS_FILING
|
||||
from govoplan_core.core.records import (
|
||||
CAPABILITY_RECORDS_FILING,
|
||||
record_archive_capability,
|
||||
)
|
||||
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_records.backend.db import models as record_models
|
||||
from govoplan_records.backend.search_source import create_records_search_source
|
||||
from govoplan_records.backend.service import SqlRecordRegistry
|
||||
from govoplan_records.backend.archive import (
|
||||
SIMULATION_PROVIDER_ID,
|
||||
SimulatedRecordArchiveProvider,
|
||||
)
|
||||
|
||||
|
||||
MODULE_ID = "records"
|
||||
@@ -55,6 +62,7 @@ OPTIONAL_DEPENDENCIES = (
|
||||
"dms",
|
||||
"docs",
|
||||
"policy",
|
||||
"approvals",
|
||||
"audit",
|
||||
"transparency",
|
||||
"search",
|
||||
@@ -85,6 +93,13 @@ def _records_registry(context: ModuleContext) -> SqlRecordRegistry:
|
||||
return SqlRecordRegistry(context.registry)
|
||||
|
||||
|
||||
def _simulated_archive_provider(
|
||||
context: ModuleContext,
|
||||
) -> SimulatedRecordArchiveProvider:
|
||||
del context
|
||||
return SimulatedRecordArchiveProvider()
|
||||
|
||||
|
||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
records = (
|
||||
session.query(record_models.RecordIdentity)
|
||||
@@ -100,7 +115,32 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
)
|
||||
.count()
|
||||
)
|
||||
return {"records": records, "open_records": open_records}
|
||||
active_holds = (
|
||||
session.query(record_models.RecordHoldRevision)
|
||||
.filter(
|
||||
record_models.RecordHoldRevision.tenant_id == tenant_id,
|
||||
record_models.RecordHoldRevision.superseded_at.is_(None),
|
||||
record_models.RecordHoldRevision.status == "active",
|
||||
)
|
||||
.count()
|
||||
)
|
||||
pending_dispositions = (
|
||||
session.query(record_models.RecordDispositionRevision)
|
||||
.filter(
|
||||
record_models.RecordDispositionRevision.tenant_id == tenant_id,
|
||||
record_models.RecordDispositionRevision.superseded_at.is_(None),
|
||||
record_models.RecordDispositionRevision.status.in_(
|
||||
("review_pending", "review_unavailable")
|
||||
),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
return {
|
||||
"records": records,
|
||||
"open_records": open_records,
|
||||
"active_holds": active_holds,
|
||||
"pending_dispositions": pending_dispositions,
|
||||
}
|
||||
|
||||
|
||||
PERMISSIONS = (
|
||||
@@ -247,14 +287,16 @@ DOCUMENTATION = (
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="records.lifecycle-limitations",
|
||||
title="Records lifecycle limitations",
|
||||
summary="Identifies lifecycle controls intentionally deferred beyond the native kernel.",
|
||||
id="records.lifecycle",
|
||||
title="Governed records lifecycle",
|
||||
summary="Close, retain, hold, appraise, approve, and package records with durable evidence.",
|
||||
body=(
|
||||
"The current vertical supports planned and open records. Restricted object grants, closure, "
|
||||
"retention calculation, holds, appraisal, disposition, transfer, destruction, and external "
|
||||
"archive effects are separate governed work packages. No destructive effect is implied by "
|
||||
"enabling Records."
|
||||
"Closing a record applies the versioned record-class retention rule. Reopening preserves the "
|
||||
"previous schedule unless an administrator deliberately restarts it. Effective-dated holds block "
|
||||
"disposition and transfer. Appraisal selects retain, transfer, destroy, or reclassify; a disposition "
|
||||
"binds the exact evidence digest and requires independent approval through Approvals. Approval only "
|
||||
"changes lifecycle state. Destruction remains pending and transfer simulation never claims custody. "
|
||||
"Every API mutation is fenced and recorded in the Core recovery ledger."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
@@ -263,17 +305,44 @@ DOCUMENTATION = (
|
||||
related_modules=("policy", "approvals", "audit", "dms"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Grenzen des Aktenlebenszyklus",
|
||||
"summary": "Kennzeichnet bewusst nach dem nativen Kern umzusetzende Lebenszyklussteuerungen.",
|
||||
"title": "Gesteuerter Aktenlebenszyklus",
|
||||
"summary": "Akten mit dauerhaftem Nachweis abschließen, aufbewahren, sperren, bewerten, freigeben und paketieren.",
|
||||
"body": (
|
||||
"Der aktuelle Stand unterstützt geplante und offene Akten. Objektbezogene Freigaben, "
|
||||
"Abschluss, Aufbewahrungsberechnung, Sperren, Bewertung, Aussonderung, Übergabe, Vernichtung "
|
||||
"und externe Archiveffekte sind getrennte gesteuerte Arbeitspakete. Die Aktivierung von "
|
||||
"Records löst keine vernichtende Wirkung aus."
|
||||
"Beim Abschluss einer Akte wird die versionierte Aufbewahrungsregel der Aktenklasse angewendet. "
|
||||
"Eine Wiedereröffnung bewahrt den bisherigen Zeitplan, sofern ein Administrator ihn nicht "
|
||||
"bewusst neu startet. Gültigkeitsbezogene Sperren blockieren Aussonderung und Übergabe. Die "
|
||||
"Bewertung wählt Aufbewahrung, Übergabe, Vernichtung oder Neuklassifikation; die Aussonderung "
|
||||
"bindet den exakten Nachweis und benötigt eine unabhängige Freigabe durch Approvals. Eine "
|
||||
"Freigabe ändert nur den Lebenszyklusstatus. Vernichtung bleibt vorgemerkt, und eine Simulation "
|
||||
"behauptet keine Archivverwahrung. Jede API-Änderung wird im Recovery-Ledger abgesichert."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={"known_limit": True},
|
||||
metadata={
|
||||
"help_contexts": [
|
||||
"records.lifecycle",
|
||||
"records.lifecycle.volume",
|
||||
"records.lifecycle.close",
|
||||
"records.lifecycle.reopen",
|
||||
"records.lifecycle.appraise",
|
||||
"records.lifecycle.hold",
|
||||
"records.lifecycle.release-hold",
|
||||
"records.lifecycle.disposition",
|
||||
"records.lifecycle.finalize",
|
||||
"records.lifecycle.prepare-transfer",
|
||||
"records.lifecycle.dispatch-transfer",
|
||||
"records.lifecycle.recovery",
|
||||
"records.catalog.admin",
|
||||
"records.field.volume",
|
||||
"records.field.volume-label",
|
||||
"records.field.retention-trigger",
|
||||
"records.field.disposition",
|
||||
"records.field.hold-authority",
|
||||
"records.field.lifecycle-reason",
|
||||
"records.field.archive-provider",
|
||||
"records.field.archive-profile",
|
||||
],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -361,14 +430,25 @@ manifest = ModuleManifest(
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="records.registry", version="1.0.0"),
|
||||
ModuleInterfaceProvider(name="records.filing", version="1.0.0"),
|
||||
ModuleInterfaceProvider(name="records.archive", version="1.0.0"),
|
||||
),
|
||||
capability_factories={CAPABILITY_RECORDS_FILING: _records_registry},
|
||||
capability_factories={
|
||||
CAPABILITY_RECORDS_FILING: _records_registry,
|
||||
record_archive_capability(SIMULATION_PROVIDER_ID): _simulated_archive_provider,
|
||||
},
|
||||
capability_documentation={
|
||||
CAPABILITY_RECORDS_FILING: CapabilityDocumentation(
|
||||
label="Record filing",
|
||||
summary="Resolves authorized exact source revisions and files immutable record items.",
|
||||
contract_version="1.0.0",
|
||||
),
|
||||
record_archive_capability(SIMULATION_PROVIDER_ID): CapabilityDocumentation(
|
||||
label="Record archive simulation",
|
||||
summary=(
|
||||
"Validates archive-neutral package and receipt handling without transferring custody."
|
||||
),
|
||||
contract_version="1.0.0",
|
||||
),
|
||||
},
|
||||
migration_spec=MigrationSpec(
|
||||
module_id=MODULE_ID,
|
||||
@@ -377,6 +457,9 @@ manifest = ModuleManifest(
|
||||
retirement_supported=True,
|
||||
retirement_provider=drop_table_retirement_provider(
|
||||
record_models.RecordChronologyEntry,
|
||||
record_models.RecordTransferPackageRevision,
|
||||
record_models.RecordDispositionRevision,
|
||||
record_models.RecordHoldRevision,
|
||||
record_models.RecordItem,
|
||||
record_models.RecordVolumeRevision,
|
||||
record_models.RecordRevision,
|
||||
@@ -396,6 +479,9 @@ manifest = ModuleManifest(
|
||||
record_models.RecordRevision,
|
||||
record_models.RecordItem,
|
||||
record_models.RecordChronologyEntry,
|
||||
record_models.RecordHoldRevision,
|
||||
record_models.RecordDispositionRevision,
|
||||
record_models.RecordTransferPackageRevision,
|
||||
record_models.RecordClassRevision,
|
||||
record_models.RecordFilePlanRevision,
|
||||
label="Records",
|
||||
@@ -417,6 +503,9 @@ manifest = ModuleManifest(
|
||||
"record_item",
|
||||
"record_class",
|
||||
"file_plan_node",
|
||||
"record_hold",
|
||||
"record_disposition",
|
||||
"record_transfer_package",
|
||||
),
|
||||
evidence=(
|
||||
"src/govoplan_records/backend/service.py",
|
||||
@@ -433,10 +522,16 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
retention=InformationGovernanceDimension(
|
||||
adoption="contract_only",
|
||||
limitation=(
|
||||
"Record classes preserve retention inputs; closure, holds, calculation, appraisal, "
|
||||
"and disposition are tracked in Records #5."
|
||||
adoption="enforced",
|
||||
object_types=(
|
||||
"record",
|
||||
"record_class",
|
||||
"record_hold",
|
||||
"record_disposition",
|
||||
),
|
||||
evidence=(
|
||||
"src/govoplan_records/backend/service.py",
|
||||
"tests/test_records.py",
|
||||
),
|
||||
),
|
||||
institutional_context=InformationGovernanceDimension(
|
||||
@@ -462,8 +557,8 @@ manifest = ModuleManifest(
|
||||
documentation_ref="docs/EAKTE_ARCHITECTURE.md",
|
||||
test_ref="tests/test_records.py",
|
||||
known_limits=(
|
||||
"Restricted object grants and lifecycle stages after open are tracked separately.",
|
||||
"Archive transfer and destructive effects are not part of the native kernel.",
|
||||
"Restricted object grants are tracked separately.",
|
||||
"Archive packaging is native, but real target conformance and destructive effects remain external work.",
|
||||
),
|
||||
supported_authority_modes=(
|
||||
"native_authoritative",
|
||||
@@ -481,6 +576,10 @@ manifest = ModuleManifest(
|
||||
"record item",
|
||||
"filing decision",
|
||||
"record chronology",
|
||||
"record hold",
|
||||
"record appraisal",
|
||||
"record disposition",
|
||||
"record transfer package",
|
||||
),
|
||||
non_owned_concepts=(
|
||||
"file content",
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Add governed Records lifecycle evidence.
|
||||
|
||||
Revision ID: 7f5b3d9a2c1e
|
||||
Revises: 6e4a2c8f1d9b
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "7f5b3d9a2c1e"
|
||||
down_revision = "6e4a2c8f1d9b"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("record_revisions") as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column("retention_started_at", sa.DateTime(timezone=True), nullable=True)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column("retention_due_at", sa.DateTime(timezone=True), nullable=True)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"retention_rule",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'"),
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column("appraisal_state", sa.String(length=40), nullable=True)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"appraisal",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'"),
|
||||
)
|
||||
)
|
||||
batch_op.create_index(
|
||||
op.f("ix_record_revisions_closed_at"), ["closed_at"], unique=False
|
||||
)
|
||||
batch_op.create_index(
|
||||
op.f("ix_record_revisions_retention_started_at"),
|
||||
["retention_started_at"],
|
||||
unique=False,
|
||||
)
|
||||
batch_op.create_index(
|
||||
op.f("ix_record_revisions_retention_due_at"),
|
||||
["retention_due_at"],
|
||||
unique=False,
|
||||
)
|
||||
batch_op.create_index(
|
||||
op.f("ix_record_revisions_appraisal_state"),
|
||||
["appraisal_state"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"record_hold_revisions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("hold_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("record_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("status", sa.String(length=30), nullable=False),
|
||||
sa.Column("reason", sa.Text(), nullable=False),
|
||||
sa.Column("authority", sa.String(length=500), nullable=False),
|
||||
sa.Column("scope", sa.JSON(), nullable=False),
|
||||
sa.Column("effective_from", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("effective_to", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("released_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("policy_refs", sa.JSON(), nullable=False),
|
||||
sa.Column("institutional_context", sa.JSON(), nullable=False),
|
||||
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("changed_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["previous_revision_id"],
|
||||
["record_hold_revisions.id"],
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "hold_id", "revision", name="uq_record_hold_revision"
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "idempotency_key", name="uq_record_hold_idempotency"
|
||||
),
|
||||
)
|
||||
_indexes(
|
||||
"record_hold_revisions",
|
||||
"tenant_id",
|
||||
"hold_id",
|
||||
"record_id",
|
||||
"previous_revision_id",
|
||||
"status",
|
||||
"effective_from",
|
||||
"effective_to",
|
||||
"recorded_at",
|
||||
"superseded_at",
|
||||
"changed_by",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_record_hold_current",
|
||||
"record_hold_revisions",
|
||||
["tenant_id", "hold_id", "superseded_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_record_hold_record",
|
||||
"record_hold_revisions",
|
||||
["tenant_id", "record_id", "status"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"record_disposition_revisions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("disposition_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("record_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("action", sa.String(length=30), nullable=False),
|
||||
sa.Column("status", sa.String(length=40), nullable=False),
|
||||
sa.Column("reason", sa.Text(), nullable=False),
|
||||
sa.Column("subject_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("subject_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("consequence_preview", sa.JSON(), nullable=False),
|
||||
sa.Column("policy_refs", sa.JSON(), nullable=False),
|
||||
sa.Column("approval_request_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("proposed_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("reviewed_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("institutional_context", sa.JSON(), nullable=False),
|
||||
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["previous_revision_id"],
|
||||
["record_disposition_revisions.id"],
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"disposition_id",
|
||||
"revision",
|
||||
name="uq_record_disposition_revision",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_record_disposition_idempotency",
|
||||
),
|
||||
)
|
||||
_indexes(
|
||||
"record_disposition_revisions",
|
||||
"tenant_id",
|
||||
"disposition_id",
|
||||
"record_id",
|
||||
"previous_revision_id",
|
||||
"action",
|
||||
"status",
|
||||
"approval_request_id",
|
||||
"proposed_by",
|
||||
"reviewed_by",
|
||||
"recorded_at",
|
||||
"superseded_at",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_record_disposition_current",
|
||||
"record_disposition_revisions",
|
||||
["tenant_id", "disposition_id", "superseded_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_record_disposition_record",
|
||||
"record_disposition_revisions",
|
||||
["tenant_id", "record_id", "status"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"record_transfer_package_revisions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("package_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("record_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("disposition_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("record_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("provider_id", sa.String(length=100), nullable=False),
|
||||
sa.Column("profile", sa.String(length=255), nullable=False),
|
||||
sa.Column("status", sa.String(length=40), nullable=False),
|
||||
sa.Column("authority_mode", sa.String(length=40), nullable=False),
|
||||
sa.Column("manifest", sa.JSON(), nullable=False),
|
||||
sa.Column("manifest_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("receipt", sa.JSON(), nullable=False),
|
||||
sa.Column("receipt_sha256", sa.String(length=64), nullable=True),
|
||||
sa.Column("external_reference", sa.String(length=1500), nullable=True),
|
||||
sa.Column("recovery_operation_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("simulated", sa.Boolean(), nullable=False),
|
||||
sa.Column("institutional_context", sa.JSON(), nullable=False),
|
||||
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("changed_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["previous_revision_id"],
|
||||
["record_transfer_package_revisions.id"],
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"package_id",
|
||||
"revision",
|
||||
name="uq_record_transfer_package_revision",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_record_transfer_package_idempotency",
|
||||
),
|
||||
)
|
||||
_indexes(
|
||||
"record_transfer_package_revisions",
|
||||
"tenant_id",
|
||||
"package_id",
|
||||
"record_id",
|
||||
"disposition_id",
|
||||
"previous_revision_id",
|
||||
"provider_id",
|
||||
"status",
|
||||
"recovery_operation_id",
|
||||
"recorded_at",
|
||||
"superseded_at",
|
||||
"changed_by",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_record_transfer_package_current",
|
||||
"record_transfer_package_revisions",
|
||||
["tenant_id", "package_id", "superseded_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_record_transfer_package_record",
|
||||
"record_transfer_package_revisions",
|
||||
["tenant_id", "record_id", "status"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("record_transfer_package_revisions")
|
||||
op.drop_table("record_disposition_revisions")
|
||||
op.drop_table("record_hold_revisions")
|
||||
|
||||
with op.batch_alter_table("record_revisions") as batch_op:
|
||||
batch_op.drop_index(op.f("ix_record_revisions_appraisal_state"))
|
||||
batch_op.drop_index(op.f("ix_record_revisions_retention_due_at"))
|
||||
batch_op.drop_index(op.f("ix_record_revisions_retention_started_at"))
|
||||
batch_op.drop_index(op.f("ix_record_revisions_closed_at"))
|
||||
batch_op.drop_column("appraisal")
|
||||
batch_op.drop_column("appraisal_state")
|
||||
batch_op.drop_column("retention_rule")
|
||||
batch_op.drop_column("retention_due_at")
|
||||
batch_op.drop_column("retention_started_at")
|
||||
batch_op.drop_column("closed_at")
|
||||
|
||||
|
||||
def _indexes(table: str, *columns: str) -> None:
|
||||
for column in columns:
|
||||
op.create_index(op.f(f"ix_{table}_{column}"), table, [column], unique=False)
|
||||
@@ -0,0 +1,203 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from contextvars import ContextVar, Token
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_core.core.recovery import (
|
||||
RecoveryGuaranteeError,
|
||||
RecoveryMode,
|
||||
RecoveryPlan,
|
||||
)
|
||||
from govoplan_core.core.recovery_runtime import (
|
||||
DurableRecoveryOperation,
|
||||
RecoveryOperationBusy,
|
||||
RecoveryOperationStateConflict,
|
||||
begin_durable_recovery_operation,
|
||||
)
|
||||
from govoplan_core.core.runtime_coordination import process_runtime_identity
|
||||
|
||||
|
||||
class RecordRecoveryError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
_recovery_operation_id: ContextVar[str | None] = ContextVar(
|
||||
"records_recovery_operation_id", default=None
|
||||
)
|
||||
|
||||
|
||||
def bind_record_recovery_operation(operation_id: str) -> Token[str | None]:
|
||||
return _recovery_operation_id.set(operation_id)
|
||||
|
||||
|
||||
def current_record_recovery_operation() -> str | None:
|
||||
return _recovery_operation_id.get()
|
||||
|
||||
|
||||
def reset_record_recovery_operation(token: Token[str | None]) -> None:
|
||||
_recovery_operation_id.reset(token)
|
||||
|
||||
|
||||
def record_session_factory(session: Session) -> sessionmaker[Session]:
|
||||
bind = session.get_bind()
|
||||
if bind is None:
|
||||
raise RecordRecoveryError("Records recovery requires a bound database session.")
|
||||
return sessionmaker(bind=bind, expire_on_commit=False)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RecordAtomicRecovery:
|
||||
operation: DurableRecoveryOperation | None
|
||||
operation_id: str
|
||||
replayed: bool
|
||||
|
||||
def commit_success(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
result: object,
|
||||
resource_id: str,
|
||||
) -> None:
|
||||
evidence = {
|
||||
"verified": True,
|
||||
"resource_id": resource_id,
|
||||
"result_sha256": _canonical_sha256(result),
|
||||
"domain_and_checkpoint_atomic": True,
|
||||
"checks": {
|
||||
"domain_result_digest_recorded": True,
|
||||
"domain_and_checkpoint_atomic": True,
|
||||
},
|
||||
}
|
||||
if self.operation is None:
|
||||
session.commit()
|
||||
return
|
||||
self.operation.commit_atomic_success(session, evidence=evidence)
|
||||
|
||||
def reject(self, *, summary: str, error_type: str) -> None:
|
||||
if self.operation is None:
|
||||
return
|
||||
self.operation.reject(
|
||||
summary=summary,
|
||||
evidence={
|
||||
"verified": True,
|
||||
"domain_mutation_committed": False,
|
||||
"error_type": error_type,
|
||||
"checks": {"definitive_domain_rejection": True},
|
||||
},
|
||||
)
|
||||
|
||||
def fail(self, *, summary: str, error_type: str) -> None:
|
||||
if self.operation is None:
|
||||
return
|
||||
self.operation.fail(
|
||||
summary=summary,
|
||||
evidence={
|
||||
"verified": True,
|
||||
"domain_mutation_committed": False,
|
||||
"error_type": error_type,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def begin_record_atomic_recovery(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
operation_type: str,
|
||||
idempotency_key: str,
|
||||
request: dict[str, Any],
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
) -> RecordAtomicRecovery:
|
||||
operation_key = hashlib.sha256(
|
||||
f"{tenant_id}:{operation_type}:{idempotency_key}".encode("utf-8")
|
||||
).hexdigest()
|
||||
lease_id = hashlib.sha256(
|
||||
f"{tenant_id}:{resource_type}:{resource_id}".encode("utf-8")
|
||||
).hexdigest()
|
||||
try:
|
||||
started = begin_durable_recovery_operation(
|
||||
record_session_factory(session),
|
||||
identity=process_runtime_identity(),
|
||||
module_id="records",
|
||||
operation_type=operation_type,
|
||||
idempotency_key=f"records:{operation_key}",
|
||||
request={"tenant_id": tenant_id, **request},
|
||||
recovery_plan=RecoveryPlan(
|
||||
mode=RecoveryMode.ATOMIC,
|
||||
preconditions=(
|
||||
"the current actor is authorized for the Records mutation",
|
||||
"the expected revision and idempotency key are present",
|
||||
"the target resource has no unresolved recovery operation",
|
||||
),
|
||||
verification_steps=(
|
||||
"commit the immutable domain revision and chronology entry",
|
||||
"commit the terminal recovery checkpoint in the same transaction",
|
||||
"verify the result digest and recovery evidence chain",
|
||||
),
|
||||
),
|
||||
precondition_evidence={
|
||||
"tenant_id": tenant_id,
|
||||
"resource_type": resource_type,
|
||||
"resource_id_sha256": hashlib.sha256(
|
||||
resource_id.encode("utf-8")
|
||||
).hexdigest(),
|
||||
"request_sha256": _canonical_sha256(request),
|
||||
"external_effect": False,
|
||||
},
|
||||
lease_resource_key=f"records:{tenant_id}:{lease_id}",
|
||||
lease_ttl_seconds=5 * 60,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
metadata={
|
||||
"resources": ["postgresql"],
|
||||
"external_effect": False,
|
||||
"recovery_declaration": "records-atomic-revision",
|
||||
},
|
||||
block_unresolved_resource=True,
|
||||
)
|
||||
except RecoveryOperationBusy as exc:
|
||||
raise RecordRecoveryError(
|
||||
"Another runtime is changing this Records resource."
|
||||
) from exc
|
||||
except RecoveryOperationStateConflict as exc:
|
||||
raise RecordRecoveryError(
|
||||
"This Records resource has an active or unresolved recovery operation."
|
||||
) from exc
|
||||
except (RecoveryGuaranteeError, RuntimeError) as exc:
|
||||
raise RecordRecoveryError(
|
||||
"The recovery ledger is unavailable; the Records mutation was not started."
|
||||
) from exc
|
||||
return RecordAtomicRecovery(
|
||||
operation=started.operation,
|
||||
operation_id=started.operation_id,
|
||||
replayed=started.replayed,
|
||||
)
|
||||
|
||||
|
||||
def _canonical_sha256(value: object) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RecordAtomicRecovery",
|
||||
"RecordRecoveryError",
|
||||
"begin_record_atomic_recovery",
|
||||
"bind_record_recovery_operation",
|
||||
"current_record_recovery_operation",
|
||||
"record_session_factory",
|
||||
"reset_record_recovery_operation",
|
||||
]
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
@@ -7,18 +8,31 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.core.records import RecordFilingRequest, RecordSourceLocator
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_records.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE
|
||||
from govoplan_records.backend.schemas import (
|
||||
FilePlanNodeWriteRequest,
|
||||
RecordAppraisalRequest,
|
||||
RecordArchiveProviderResponse,
|
||||
RecordCatalogResponse,
|
||||
RecordClassWriteRequest,
|
||||
RecordCloseRequest,
|
||||
RecordCreateRequest,
|
||||
RecordDetailResponse,
|
||||
RecordDispositionCreateRequest,
|
||||
RecordDispositionFinalizeRequest,
|
||||
RecordDispositionWithdrawRequest,
|
||||
RecordHoldCreateRequest,
|
||||
RecordHoldReleaseRequest,
|
||||
RecordItemCreateRequest,
|
||||
RecordLifecycleActionRequest,
|
||||
RecordListResponse,
|
||||
RecordRecoveryStatusResponse,
|
||||
RecordSourceProviderResponse,
|
||||
RecordTransferDispatchRequest,
|
||||
RecordTransferPackageCreateRequest,
|
||||
RecordUpdateRequest,
|
||||
RecordVolumeCreateRequest,
|
||||
)
|
||||
@@ -29,6 +43,12 @@ from govoplan_records.backend.service import (
|
||||
RecordStoreError,
|
||||
SqlRecordRegistry,
|
||||
)
|
||||
from govoplan_records.backend.recovery import (
|
||||
RecordRecoveryError,
|
||||
begin_record_atomic_recovery,
|
||||
bind_record_recovery_operation,
|
||||
reset_record_recovery_operation,
|
||||
)
|
||||
|
||||
|
||||
def create_router(registry: object | None = None) -> APIRouter:
|
||||
@@ -59,6 +79,12 @@ def create_router(registry: object | None = None) -> APIRouter:
|
||||
lambda: records.write_file_plan_node(
|
||||
session, principal, payload=payload.model_dump(mode="python")
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="catalog.file_plan.write",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request=payload.model_dump(mode="json"),
|
||||
resource_type="record_file_plan_node",
|
||||
resource_id=payload.node_id,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
@@ -77,6 +103,12 @@ def create_router(registry: object | None = None) -> APIRouter:
|
||||
lambda: records.write_record_class(
|
||||
session, principal, payload=payload.model_dump(mode="python")
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="catalog.class.write",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request=payload.model_dump(mode="json"),
|
||||
resource_type="record_class",
|
||||
resource_id=payload.class_id,
|
||||
)
|
||||
|
||||
@router.get("/sources", response_model=RecordSourceProviderResponse)
|
||||
@@ -89,6 +121,16 @@ def create_router(registry: object | None = None) -> APIRouter:
|
||||
providers=records.source_providers(session, principal)
|
||||
)
|
||||
|
||||
@router.get("/archive-providers", response_model=RecordArchiveProviderResponse)
|
||||
def api_archive_providers(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> RecordArchiveProviderResponse:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
return RecordArchiveProviderResponse(
|
||||
providers=records.archive_providers(session, principal)
|
||||
)
|
||||
|
||||
@router.get("", response_model=RecordListResponse)
|
||||
def api_list_records(
|
||||
query: str | None = Query(default=None, max_length=500),
|
||||
@@ -127,6 +169,12 @@ def create_router(registry: object | None = None) -> APIRouter:
|
||||
lambda: records.create_record(
|
||||
session, principal, payload=payload.model_dump(mode="python")
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="record.create",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request=payload.model_dump(mode="json"),
|
||||
resource_type="record",
|
||||
resource_id=payload.record_id or payload.record_number,
|
||||
)
|
||||
|
||||
@router.get("/{record_id}", response_model=RecordDetailResponse)
|
||||
@@ -146,6 +194,20 @@ def create_router(registry: object | None = None) -> APIRouter:
|
||||
except RecordStoreError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
|
||||
@router.get("/{record_id}/recovery", response_model=RecordRecoveryStatusResponse)
|
||||
def api_record_recovery_status(
|
||||
record_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> RecordRecoveryStatusResponse:
|
||||
_require(principal, ADMIN_SCOPE)
|
||||
try:
|
||||
return RecordRecoveryStatusResponse(
|
||||
**records.recovery_status(session, principal, record_id=record_id)
|
||||
)
|
||||
except RecordStoreError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
|
||||
@router.patch("/{record_id}", response_model=dict[str, Any])
|
||||
def api_update_record(
|
||||
record_id: str,
|
||||
@@ -162,6 +224,294 @@ def create_router(registry: object | None = None) -> APIRouter:
|
||||
record_id=record_id,
|
||||
payload=payload.model_dump(mode="python", exclude_unset=True),
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="record.revise",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request=payload.model_dump(mode="json", exclude_unset=True),
|
||||
resource_type="record",
|
||||
resource_id=record_id,
|
||||
)
|
||||
|
||||
@router.post("/{record_id}/close", response_model=dict[str, Any])
|
||||
def api_close_record(
|
||||
record_id: str,
|
||||
payload: RecordCloseRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
if payload.restart_retention:
|
||||
_require(principal, ADMIN_SCOPE)
|
||||
return _write(
|
||||
session,
|
||||
lambda: records.close_record(
|
||||
session,
|
||||
principal,
|
||||
record_id=record_id,
|
||||
payload=payload.model_dump(mode="python"),
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="record.close",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request=payload.model_dump(mode="json"),
|
||||
resource_type="record",
|
||||
resource_id=record_id,
|
||||
)
|
||||
|
||||
@router.post("/{record_id}/reopen", response_model=dict[str, Any])
|
||||
def api_reopen_record(
|
||||
record_id: str,
|
||||
payload: RecordLifecycleActionRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
return _write(
|
||||
session,
|
||||
lambda: records.reopen_record(
|
||||
session,
|
||||
principal,
|
||||
record_id=record_id,
|
||||
payload=payload.model_dump(mode="python"),
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="record.reopen",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request=payload.model_dump(mode="json"),
|
||||
resource_type="record",
|
||||
resource_id=record_id,
|
||||
)
|
||||
|
||||
@router.post("/{record_id}/appraise", response_model=dict[str, Any])
|
||||
def api_appraise_record(
|
||||
record_id: str,
|
||||
payload: RecordAppraisalRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
if payload.override_retention_not_due:
|
||||
_require(principal, ADMIN_SCOPE)
|
||||
return _write(
|
||||
session,
|
||||
lambda: records.appraise_record(
|
||||
session,
|
||||
principal,
|
||||
record_id=record_id,
|
||||
payload=payload.model_dump(mode="python"),
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="record.appraise",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request=payload.model_dump(mode="json"),
|
||||
resource_type="record",
|
||||
resource_id=record_id,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/{record_id}/holds",
|
||||
response_model=dict[str, Any],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def api_apply_hold(
|
||||
record_id: str,
|
||||
payload: RecordHoldCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
return _write(
|
||||
session,
|
||||
lambda: records.apply_hold(
|
||||
session,
|
||||
principal,
|
||||
record_id=record_id,
|
||||
payload=payload.model_dump(mode="python"),
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="hold.apply",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request=payload.model_dump(mode="json"),
|
||||
resource_type="record",
|
||||
resource_id=record_id,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/{record_id}/holds/{hold_id}/release",
|
||||
response_model=dict[str, Any],
|
||||
)
|
||||
def api_release_hold(
|
||||
record_id: str,
|
||||
hold_id: str,
|
||||
payload: RecordHoldReleaseRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
return _write(
|
||||
session,
|
||||
lambda: records.release_hold(
|
||||
session,
|
||||
principal,
|
||||
record_id=record_id,
|
||||
hold_id=hold_id,
|
||||
payload=payload.model_dump(mode="python"),
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="hold.release",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request={"hold_id": hold_id, **payload.model_dump(mode="json")},
|
||||
resource_type="record",
|
||||
resource_id=record_id,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/{record_id}/dispositions",
|
||||
response_model=dict[str, Any],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def api_propose_disposition(
|
||||
record_id: str,
|
||||
payload: RecordDispositionCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
return _write(
|
||||
session,
|
||||
lambda: records.propose_disposition(
|
||||
session,
|
||||
principal,
|
||||
record_id=record_id,
|
||||
payload=payload.model_dump(mode="python"),
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="disposition.propose",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request=payload.model_dump(mode="json"),
|
||||
resource_type="record",
|
||||
resource_id=record_id,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/{record_id}/dispositions/{disposition_id}/finalize",
|
||||
response_model=dict[str, Any],
|
||||
)
|
||||
def api_finalize_disposition(
|
||||
record_id: str,
|
||||
disposition_id: str,
|
||||
payload: RecordDispositionFinalizeRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
return _write(
|
||||
session,
|
||||
lambda: records.finalize_disposition(
|
||||
session,
|
||||
principal,
|
||||
record_id=record_id,
|
||||
disposition_id=disposition_id,
|
||||
payload=payload.model_dump(mode="python"),
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="disposition.finalize",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request={
|
||||
"disposition_id": disposition_id,
|
||||
**payload.model_dump(mode="json"),
|
||||
},
|
||||
resource_type="record",
|
||||
resource_id=record_id,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/{record_id}/dispositions/{disposition_id}/withdraw",
|
||||
response_model=dict[str, Any],
|
||||
)
|
||||
def api_withdraw_disposition(
|
||||
record_id: str,
|
||||
disposition_id: str,
|
||||
payload: RecordDispositionWithdrawRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
return _write(
|
||||
session,
|
||||
lambda: records.withdraw_disposition(
|
||||
session,
|
||||
principal,
|
||||
record_id=record_id,
|
||||
disposition_id=disposition_id,
|
||||
payload=payload.model_dump(mode="python"),
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="disposition.withdraw",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request={
|
||||
"disposition_id": disposition_id,
|
||||
**payload.model_dump(mode="json"),
|
||||
},
|
||||
resource_type="record",
|
||||
resource_id=record_id,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/{record_id}/transfer-packages",
|
||||
response_model=dict[str, Any],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def api_prepare_transfer_package(
|
||||
record_id: str,
|
||||
payload: RecordTransferPackageCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
return _write(
|
||||
session,
|
||||
lambda: records.prepare_transfer_package(
|
||||
session,
|
||||
principal,
|
||||
record_id=record_id,
|
||||
payload=payload.model_dump(mode="python"),
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="transfer.prepare",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request=payload.model_dump(mode="json"),
|
||||
resource_type="record",
|
||||
resource_id=record_id,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/{record_id}/transfer-packages/{package_id}/dispatch",
|
||||
response_model=dict[str, Any],
|
||||
)
|
||||
def api_dispatch_transfer_package(
|
||||
record_id: str,
|
||||
package_id: str,
|
||||
payload: RecordTransferDispatchRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
return _write(
|
||||
session,
|
||||
lambda: records.dispatch_transfer_package(
|
||||
session,
|
||||
principal,
|
||||
record_id=record_id,
|
||||
package_id=package_id,
|
||||
payload=payload.model_dump(mode="python"),
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="transfer.simulate",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request={"package_id": package_id, **payload.model_dump(mode="json")},
|
||||
resource_type="record",
|
||||
resource_id=record_id,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
@@ -184,6 +534,12 @@ def create_router(registry: object | None = None) -> APIRouter:
|
||||
record_id=record_id,
|
||||
payload=payload.model_dump(mode="python"),
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="volume.create",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request=payload.model_dump(mode="json"),
|
||||
resource_type="record",
|
||||
resource_id=record_id,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
@@ -235,7 +591,16 @@ def create_router(registry: object | None = None) -> APIRouter:
|
||||
},
|
||||
}
|
||||
|
||||
return _write(session, operation)
|
||||
return _write(
|
||||
session,
|
||||
operation,
|
||||
principal=principal,
|
||||
operation_type="item.file",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request=payload.model_dump(mode="json"),
|
||||
resource_type="record",
|
||||
resource_id=record_id,
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
@@ -245,18 +610,69 @@ def _require(principal: ApiPrincipal, scope: str) -> None:
|
||||
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
|
||||
|
||||
|
||||
def _write(session: Session, operation):
|
||||
def _write(
|
||||
session: Session,
|
||||
operation,
|
||||
*,
|
||||
principal: ApiPrincipal,
|
||||
operation_type: str,
|
||||
idempotency_key: str,
|
||||
request: dict[str, Any],
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
):
|
||||
recovery = None
|
||||
try:
|
||||
result = operation()
|
||||
session.commit()
|
||||
recovery = begin_record_atomic_recovery(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
operation_type=operation_type,
|
||||
idempotency_key=idempotency_key,
|
||||
request=request,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
)
|
||||
recovery_token = bind_record_recovery_operation(recovery.operation_id)
|
||||
try:
|
||||
result = operation()
|
||||
finally:
|
||||
reset_record_recovery_operation(recovery_token)
|
||||
if not recovery.replayed:
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action=f"records.{operation_type}",
|
||||
object_type=resource_type,
|
||||
object_id=resource_id,
|
||||
details={
|
||||
"recovery_operation_id": recovery.operation_id,
|
||||
"idempotency_key_sha256": _sha256(idempotency_key),
|
||||
},
|
||||
commit=False,
|
||||
)
|
||||
recovery.commit_success(session, result=result, resource_id=resource_id)
|
||||
return result
|
||||
except (RecordStoreError, IntegrityError) as exc:
|
||||
session.rollback()
|
||||
if recovery is not None:
|
||||
recovery.reject(summary=str(exc), error_type=type(exc).__name__)
|
||||
if isinstance(exc, IntegrityError):
|
||||
raise HTTPException(
|
||||
status_code=409, detail="The record write conflicts with existing data."
|
||||
) from exc
|
||||
raise _http_error(exc) from exc
|
||||
except RecordRecoveryError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
session.rollback()
|
||||
if recovery is not None:
|
||||
recovery.fail(summary=str(exc), error_type=type(exc).__name__)
|
||||
raise
|
||||
|
||||
|
||||
def _sha256(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _http_error(exc: RecordStoreError) -> HTTPException:
|
||||
|
||||
@@ -148,6 +148,99 @@ class RecordItemCreateRequest(StrictModel):
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RecordLifecycleActionRequest(StrictModel):
|
||||
expected_revision: int = Field(ge=1)
|
||||
purpose: str = Field(min_length=1, max_length=255)
|
||||
reason: str = Field(min_length=1, max_length=2_000)
|
||||
recorded_at: datetime
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class RecordCloseRequest(RecordLifecycleActionRequest):
|
||||
retention_trigger_at: datetime | None = None
|
||||
restart_retention: bool = False
|
||||
|
||||
|
||||
class RecordAppraisalRequest(RecordLifecycleActionRequest):
|
||||
outcome: Literal["retain", "transfer", "destroy", "reclassify"]
|
||||
policy_refs: list[str] = Field(default_factory=list, max_length=100)
|
||||
override_retention_not_due: bool = False
|
||||
|
||||
|
||||
class RecordHoldCreateRequest(StrictModel):
|
||||
hold_id: str | None = Field(default=None, max_length=255)
|
||||
expected_record_revision: int = Field(ge=1)
|
||||
reason: str = Field(min_length=1, max_length=10_000)
|
||||
authority: str = Field(min_length=1, max_length=500)
|
||||
purpose: str = Field(min_length=1, max_length=255)
|
||||
scope: dict[str, Any] = Field(default_factory=dict)
|
||||
effective_from: datetime | None = None
|
||||
effective_to: datetime | None = None
|
||||
policy_refs: list[str] = Field(default_factory=list, max_length=100)
|
||||
institutional_context: dict[str, Any] = Field(default_factory=dict)
|
||||
recorded_at: datetime
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_interval(self):
|
||||
if (
|
||||
self.effective_from
|
||||
and self.effective_to
|
||||
and self.effective_to <= self.effective_from
|
||||
):
|
||||
raise ValueError("effective_to must be after effective_from")
|
||||
return self
|
||||
|
||||
|
||||
class RecordHoldReleaseRequest(StrictModel):
|
||||
expected_hold_revision: int = Field(ge=1)
|
||||
reason: str = Field(min_length=1, max_length=2_000)
|
||||
purpose: str = Field(min_length=1, max_length=255)
|
||||
recorded_at: datetime
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class RecordDispositionCreateRequest(StrictModel):
|
||||
disposition_id: str | None = Field(default=None, max_length=255)
|
||||
expected_record_revision: int = Field(ge=1)
|
||||
action: Literal["retain", "transfer", "destroy", "reclassify"]
|
||||
reason: str = Field(min_length=1, max_length=10_000)
|
||||
purpose: str = Field(min_length=1, max_length=255)
|
||||
policy_refs: list[str] = Field(default_factory=list, max_length=100)
|
||||
institutional_context: dict[str, Any] = Field(default_factory=dict)
|
||||
recorded_at: datetime
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class RecordDispositionFinalizeRequest(StrictModel):
|
||||
expected_disposition_revision: int = Field(ge=1)
|
||||
purpose: str = Field(min_length=1, max_length=255)
|
||||
recorded_at: datetime
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class RecordDispositionWithdrawRequest(RecordDispositionFinalizeRequest):
|
||||
reason: str = Field(min_length=1, max_length=2_000)
|
||||
|
||||
|
||||
class RecordTransferPackageCreateRequest(StrictModel):
|
||||
package_id: str | None = Field(default=None, max_length=255)
|
||||
disposition_id: str = Field(min_length=1, max_length=255)
|
||||
expected_record_revision: int = Field(ge=1)
|
||||
provider_id: str = Field(min_length=1, max_length=100)
|
||||
profile: str = Field(min_length=1, max_length=255)
|
||||
purpose: str = Field(min_length=1, max_length=255)
|
||||
recorded_at: datetime
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class RecordTransferDispatchRequest(StrictModel):
|
||||
expected_package_revision: int = Field(ge=1)
|
||||
purpose: str = Field(min_length=1, max_length=255)
|
||||
recorded_at: datetime
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class RecordListResponse(StrictModel):
|
||||
records: list[dict[str, Any]]
|
||||
total: int
|
||||
@@ -165,6 +258,9 @@ class RecordDetailResponse(StrictModel):
|
||||
volumes: list[dict[str, Any]]
|
||||
items: list[dict[str, Any]]
|
||||
chronology: list[dict[str, Any]]
|
||||
holds: list[dict[str, Any]]
|
||||
dispositions: list[dict[str, Any]]
|
||||
transfer_packages: list[dict[str, Any]]
|
||||
access_explanation: dict[str, Any]
|
||||
|
||||
|
||||
@@ -172,15 +268,43 @@ class RecordSourceProviderResponse(StrictModel):
|
||||
providers: list[dict[str, Any]]
|
||||
|
||||
|
||||
class RecordArchiveProviderResponse(StrictModel):
|
||||
providers: list[dict[str, Any]]
|
||||
|
||||
|
||||
class RecordRecoveryStatusResponse(StrictModel):
|
||||
record_id: str
|
||||
record_revision: int
|
||||
evidence_sha256: str
|
||||
healthy: bool
|
||||
source_checks: list[dict[str, Any]]
|
||||
package_checks: list[dict[str, Any]]
|
||||
recovery_operations: list[dict[str, Any]]
|
||||
failure_count: int
|
||||
limitations: list[str]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FilePlanNodeWriteRequest",
|
||||
"RecordAppraisalRequest",
|
||||
"RecordArchiveProviderResponse",
|
||||
"RecordCatalogResponse",
|
||||
"RecordClassWriteRequest",
|
||||
"RecordCloseRequest",
|
||||
"RecordCreateRequest",
|
||||
"RecordDetailResponse",
|
||||
"RecordDispositionCreateRequest",
|
||||
"RecordDispositionFinalizeRequest",
|
||||
"RecordDispositionWithdrawRequest",
|
||||
"RecordHoldCreateRequest",
|
||||
"RecordHoldReleaseRequest",
|
||||
"RecordItemCreateRequest",
|
||||
"RecordLifecycleActionRequest",
|
||||
"RecordListResponse",
|
||||
"RecordRecoveryStatusResponse",
|
||||
"RecordSourceProviderResponse",
|
||||
"RecordTransferDispatchRequest",
|
||||
"RecordTransferPackageCreateRequest",
|
||||
"RecordUpdateRequest",
|
||||
"RecordVolumeCreateRequest",
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user