feat(records): govern lifecycle recovery and transfer evidence

This commit is contained in:
2026-08-06 05:36:18 +02:00
parent f316523a1d
commit df51bb1787
22 changed files with 4954 additions and 66 deletions
+91
View File
@@ -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",
]
+203
View File
@@ -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",
]
+122 -23
View File
@@ -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)
+203
View File
@@ -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",
]
+420 -4
View File
@@ -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:
+124
View File
@@ -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