3085 lines
114 KiB
Python
3085 lines
114 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping, Sequence
|
|
from datetime import UTC, datetime, timedelta
|
|
import hashlib
|
|
import json
|
|
from typing import Any
|
|
import uuid
|
|
|
|
from sqlalchemy import func, or_
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_core.core.records import (
|
|
RecordArchiveProvider,
|
|
RecordArchiveTransferRequest,
|
|
RecordContractError,
|
|
RecordFilingRequest,
|
|
RecordFilingResult,
|
|
RecordSourceLocator,
|
|
RecordSourceProvider,
|
|
RecordSourceReference,
|
|
RecordTransferPackage,
|
|
record_archive_capabilities,
|
|
record_archive_capability,
|
|
record_source_capabilities,
|
|
record_source_capability,
|
|
)
|
|
from govoplan_core.core.approvals import (
|
|
CAPABILITY_APPROVAL_REQUESTS,
|
|
ApprovalActorSelector,
|
|
ApprovalRequestCreateCommand,
|
|
ApprovalRequestProvider,
|
|
ApprovalStepDefinition,
|
|
)
|
|
from govoplan_core.core.recovery import (
|
|
RecoveryOperation,
|
|
verify_recovery_evidence_chain,
|
|
)
|
|
from govoplan_core.core.temporal import current_temporal_data_context
|
|
from govoplan_core.db.temporal import apply_temporal_revision_filter
|
|
from govoplan_records.backend.db.models import (
|
|
RecordChronologyEntry,
|
|
RecordClassRevision,
|
|
RecordDispositionRevision,
|
|
RecordFilePlanRevision,
|
|
RecordHoldRevision,
|
|
RecordIdentity,
|
|
RecordItem,
|
|
RecordRevision,
|
|
RecordTransferPackageRevision,
|
|
RecordVolumeRevision,
|
|
)
|
|
from govoplan_records.backend.recovery import current_record_recovery_operation
|
|
|
|
|
|
class RecordStoreError(ValueError):
|
|
pass
|
|
|
|
|
|
class RecordConflictError(RecordStoreError):
|
|
pass
|
|
|
|
|
|
class RecordNotFoundError(RecordStoreError):
|
|
pass
|
|
|
|
|
|
class RecordSourceUnavailableError(RecordStoreError):
|
|
pass
|
|
|
|
|
|
class SqlRecordRegistry:
|
|
def __init__(self, registry: object | None = None) -> None:
|
|
self.registry = registry
|
|
|
|
def catalog(
|
|
self, session: Session, principal: object
|
|
) -> dict[str, list[dict[str, Any]]]:
|
|
tenant_id = _tenant(principal)
|
|
file_plan_query = session.query(RecordFilePlanRevision).filter(
|
|
RecordFilePlanRevision.tenant_id == tenant_id
|
|
)
|
|
class_query = session.query(RecordClassRevision).filter(
|
|
RecordClassRevision.tenant_id == tenant_id
|
|
)
|
|
file_plan = (
|
|
apply_temporal_revision_filter(file_plan_query, RecordFilePlanRevision)
|
|
.order_by(RecordFilePlanRevision.code, RecordFilePlanRevision.label)
|
|
.all()
|
|
)
|
|
classes = (
|
|
apply_temporal_revision_filter(class_query, RecordClassRevision)
|
|
.order_by(RecordClassRevision.label)
|
|
.all()
|
|
)
|
|
return {
|
|
"file_plan": [_file_plan_dict(item) for item in file_plan],
|
|
"classes": [_class_dict(item) for item in classes],
|
|
}
|
|
|
|
def write_file_plan_node(
|
|
self,
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
payload: Mapping[str, object],
|
|
) -> dict[str, Any]:
|
|
tenant_id = _tenant(principal)
|
|
request_hash = _request_hash(payload)
|
|
replay = (
|
|
session.query(RecordFilePlanRevision)
|
|
.filter(
|
|
RecordFilePlanRevision.tenant_id == tenant_id,
|
|
RecordFilePlanRevision.idempotency_key
|
|
== str(payload["idempotency_key"]),
|
|
)
|
|
.one_or_none()
|
|
)
|
|
if replay is not None:
|
|
_verify_replay(replay.request_sha256, request_hash)
|
|
return _file_plan_dict(replay)
|
|
|
|
node_id = _text(payload, "node_id")
|
|
current = _current_file_plan(session, tenant_id, node_id, lock=True)
|
|
expected = payload.get("expected_revision")
|
|
_validate_expected(
|
|
current.revision if current else None, expected, label="File-plan node"
|
|
)
|
|
recorded_at = _timestamp(payload.get("recorded_at"), "recorded_at")
|
|
_validate_later_revision(current.recorded_at if current else None, recorded_at)
|
|
parent_node_id = _optional_text(payload.get("parent_node_id"))
|
|
_validate_file_plan_parent(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
node_id=node_id,
|
|
parent_node_id=parent_node_id,
|
|
)
|
|
duplicate = (
|
|
session.query(RecordFilePlanRevision)
|
|
.filter(
|
|
RecordFilePlanRevision.tenant_id == tenant_id,
|
|
RecordFilePlanRevision.code == _text(payload, "code"),
|
|
RecordFilePlanRevision.node_id != node_id,
|
|
RecordFilePlanRevision.superseded_at.is_(None),
|
|
)
|
|
.first()
|
|
)
|
|
if duplicate is not None:
|
|
raise RecordConflictError(
|
|
"A current file-plan node already uses this code."
|
|
)
|
|
if current is not None:
|
|
current.superseded_at = recorded_at
|
|
row = RecordFilePlanRevision(
|
|
tenant_id=tenant_id,
|
|
node_id=node_id,
|
|
revision=(current.revision + 1) if current else 1,
|
|
previous_revision_id=current.id if current else None,
|
|
parent_node_id=parent_node_id,
|
|
code=_text(payload, "code"),
|
|
label=_text(payload, "label"),
|
|
description=_optional_text(payload.get("description")),
|
|
active=bool(payload.get("active", True)),
|
|
valid_from=_optional_timestamp(payload.get("valid_from"), "valid_from"),
|
|
valid_to=_optional_timestamp(payload.get("valid_to"), "valid_to"),
|
|
recorded_at=recorded_at,
|
|
institutional_context=_mapping(payload.get("institutional_context")),
|
|
changed_by=_actor(principal),
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_sha256=request_hash,
|
|
)
|
|
_validate_interval(row.valid_from, row.valid_to)
|
|
session.add(row)
|
|
session.flush()
|
|
return _file_plan_dict(row)
|
|
|
|
def write_record_class(
|
|
self,
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
payload: Mapping[str, object],
|
|
) -> dict[str, Any]:
|
|
tenant_id = _tenant(principal)
|
|
request_hash = _request_hash(payload)
|
|
replay = (
|
|
session.query(RecordClassRevision)
|
|
.filter(
|
|
RecordClassRevision.tenant_id == tenant_id,
|
|
RecordClassRevision.idempotency_key == str(payload["idempotency_key"]),
|
|
)
|
|
.one_or_none()
|
|
)
|
|
if replay is not None:
|
|
_verify_replay(replay.request_sha256, request_hash)
|
|
return _class_dict(replay)
|
|
class_id = _text(payload, "class_id")
|
|
node_id = _text(payload, "file_plan_node_id")
|
|
if _current_file_plan(session, tenant_id, node_id) is None:
|
|
raise RecordStoreError(
|
|
"The record class requires an existing file-plan node."
|
|
)
|
|
current = _current_class(session, tenant_id, class_id, lock=True)
|
|
_validate_expected(
|
|
current.revision if current else None,
|
|
payload.get("expected_revision"),
|
|
label="Record class",
|
|
)
|
|
recorded_at = _timestamp(payload.get("recorded_at"), "recorded_at")
|
|
_validate_later_revision(current.recorded_at if current else None, recorded_at)
|
|
duplicate = (
|
|
session.query(RecordClassRevision)
|
|
.filter(
|
|
RecordClassRevision.tenant_id == tenant_id,
|
|
RecordClassRevision.key == _text(payload, "key"),
|
|
RecordClassRevision.class_id != class_id,
|
|
RecordClassRevision.superseded_at.is_(None),
|
|
)
|
|
.first()
|
|
)
|
|
if duplicate is not None:
|
|
raise RecordConflictError("A current record class already uses this key.")
|
|
if current is not None:
|
|
current.superseded_at = recorded_at
|
|
row = RecordClassRevision(
|
|
tenant_id=tenant_id,
|
|
class_id=class_id,
|
|
revision=(current.revision + 1) if current else 1,
|
|
previous_revision_id=current.id if current else None,
|
|
file_plan_node_id=node_id,
|
|
key=_text(payload, "key"),
|
|
label=_text(payload, "label"),
|
|
description=_optional_text(payload.get("description")),
|
|
metadata_requirements=_text_list(payload.get("metadata_requirements")),
|
|
allowed_source_types=_text_list(payload.get("allowed_source_types")),
|
|
retention_period_days=_optional_int(payload.get("retention_period_days")),
|
|
closure_trigger=_optional_text(payload.get("closure_trigger")),
|
|
access_mode=_text(payload, "access_mode", default="tenant"),
|
|
active=bool(payload.get("active", True)),
|
|
valid_from=_optional_timestamp(payload.get("valid_from"), "valid_from"),
|
|
valid_to=_optional_timestamp(payload.get("valid_to"), "valid_to"),
|
|
recorded_at=recorded_at,
|
|
institutional_context=_mapping(payload.get("institutional_context")),
|
|
changed_by=_actor(principal),
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_sha256=request_hash,
|
|
)
|
|
_validate_interval(row.valid_from, row.valid_to)
|
|
session.add(row)
|
|
session.flush()
|
|
return _class_dict(row)
|
|
|
|
def create_record(
|
|
self,
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
payload: Mapping[str, object],
|
|
) -> dict[str, Any]:
|
|
tenant_id = _tenant(principal)
|
|
request_hash = _request_hash(payload)
|
|
replay = _replay_event(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_hash=request_hash,
|
|
)
|
|
if replay is not None:
|
|
return self.get_record(
|
|
session,
|
|
principal,
|
|
record_id=replay.record_id,
|
|
revision=replay.record_revision,
|
|
)["record"]
|
|
class_id = _text(payload, "class_id")
|
|
node_id = _text(payload, "file_plan_node_id")
|
|
record_class = _current_class(session, tenant_id, class_id)
|
|
if record_class is None or not record_class.active:
|
|
raise RecordStoreError("The selected record class is not available.")
|
|
if record_class.file_plan_node_id != node_id:
|
|
raise RecordStoreError(
|
|
"The record class does not belong to the selected file-plan node."
|
|
)
|
|
recorded_at = _timestamp(payload.get("recorded_at"), "recorded_at")
|
|
record_id = _optional_text(payload.get("record_id")) or str(uuid.uuid4())
|
|
if _text(payload, "access_mode", default="tenant") != "tenant":
|
|
raise RecordStoreError(
|
|
"Restricted records require the object-grant slice and cannot be created yet."
|
|
)
|
|
duplicate_number = (
|
|
session.query(RecordIdentity.id)
|
|
.filter(
|
|
RecordIdentity.tenant_id == tenant_id,
|
|
RecordIdentity.record_number == _text(payload, "record_number"),
|
|
)
|
|
.first()
|
|
)
|
|
if duplicate_number is not None:
|
|
raise RecordConflictError("A record already uses this record number.")
|
|
identity = RecordIdentity(
|
|
tenant_id=tenant_id,
|
|
record_id=record_id,
|
|
record_number=_text(payload, "record_number"),
|
|
created_by=_actor(principal),
|
|
)
|
|
session.add(identity)
|
|
session.flush()
|
|
snapshot = _record_snapshot(payload)
|
|
row = RecordRevision(
|
|
tenant_id=tenant_id,
|
|
record_id=record_id,
|
|
identity_id=identity.id,
|
|
revision=1,
|
|
class_id=class_id,
|
|
file_plan_node_id=node_id,
|
|
title=_text(payload, "title"),
|
|
description=_optional_text(payload.get("description")),
|
|
state=_text(payload, "state", default="open"),
|
|
source_authority_mode=_text(
|
|
payload, "source_authority_mode", default="native_authoritative"
|
|
),
|
|
access_mode=_text(payload, "access_mode", default="tenant"),
|
|
purpose=_text(payload, "purpose"),
|
|
classification=_optional_text(payload.get("classification")),
|
|
responsible_unit_id=_optional_text(payload.get("responsible_unit_id")),
|
|
responsible_function_id=_optional_text(
|
|
payload.get("responsible_function_id")
|
|
),
|
|
external_reference=_mapping(payload.get("external_reference")),
|
|
institutional_context=_mapping(payload.get("institutional_context")),
|
|
search_text=_record_search_text(payload, identity.record_number),
|
|
valid_from=_optional_timestamp(payload.get("valid_from"), "valid_from")
|
|
or recorded_at,
|
|
valid_to=_optional_timestamp(payload.get("valid_to"), "valid_to"),
|
|
recorded_at=recorded_at,
|
|
changed_by=_actor(principal),
|
|
snapshot=snapshot,
|
|
)
|
|
_validate_interval(row.valid_from, row.valid_to)
|
|
session.add(row)
|
|
_append_event(
|
|
session,
|
|
principal,
|
|
row=row,
|
|
event_type="record.created",
|
|
summary=f"Record {identity.record_number} created",
|
|
occurred_at=recorded_at,
|
|
purpose=row.purpose,
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_hash=request_hash,
|
|
institutional_context=row.institutional_context,
|
|
payload={"change_reason": _text(payload, "change_reason")},
|
|
)
|
|
session.flush()
|
|
return _record_dict(row, identity)
|
|
|
|
def update_record(
|
|
self,
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
record_id: str,
|
|
payload: Mapping[str, object],
|
|
) -> dict[str, Any]:
|
|
tenant_id = _tenant(principal)
|
|
request_hash = _request_hash({"record_id": record_id, **payload})
|
|
replay = _replay_event(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_hash=request_hash,
|
|
)
|
|
if replay is not None:
|
|
return self.get_record(
|
|
session,
|
|
principal,
|
|
record_id=record_id,
|
|
revision=replay.record_revision,
|
|
)["record"]
|
|
current = _current_record(session, tenant_id, record_id, lock=True)
|
|
if current is None:
|
|
raise RecordNotFoundError("Record not found.")
|
|
expected = int(payload.get("expected_revision") or 0)
|
|
if current.revision != expected:
|
|
raise RecordConflictError(
|
|
f"Record revision conflict: expected {expected}, current revision is {current.revision}."
|
|
)
|
|
recorded_at = _timestamp(payload.get("recorded_at"), "recorded_at")
|
|
_validate_later_revision(current.recorded_at, recorded_at)
|
|
identity = session.get(RecordIdentity, current.identity_id)
|
|
if identity is None:
|
|
raise RecordStoreError("Record identity is missing.")
|
|
if (
|
|
current.state not in {"planned", "open"}
|
|
and "state" in payload
|
|
and str(payload["state"]) != current.state
|
|
):
|
|
raise RecordStoreError(
|
|
"Lifecycle state can only change through a governed lifecycle action."
|
|
)
|
|
values = _revision_values(current, payload)
|
|
if values["access_mode"] != "tenant":
|
|
raise RecordStoreError(
|
|
"Restricted records require the object-grant slice and cannot be enabled yet."
|
|
)
|
|
record_class = _current_class(session, tenant_id, values["class_id"])
|
|
if (
|
|
record_class is None
|
|
or record_class.file_plan_node_id != values["file_plan_node_id"]
|
|
):
|
|
raise RecordStoreError(
|
|
"The selected record class and file-plan node do not match."
|
|
)
|
|
current.superseded_at = recorded_at
|
|
row = RecordRevision(
|
|
tenant_id=tenant_id,
|
|
record_id=record_id,
|
|
identity_id=current.identity_id,
|
|
revision=current.revision + 1,
|
|
previous_revision_id=current.id,
|
|
recorded_at=recorded_at,
|
|
changed_by=_actor(principal),
|
|
search_text=" ".join(
|
|
value
|
|
for value in (
|
|
identity.record_number,
|
|
str(values["title"]),
|
|
str(values["description"] or ""),
|
|
str(values["classification"] or ""),
|
|
)
|
|
if value
|
|
).lower(),
|
|
snapshot={**dict(current.snapshot), **_json_mapping(payload)},
|
|
**values,
|
|
)
|
|
_validate_interval(row.valid_from, row.valid_to)
|
|
session.add(row)
|
|
_append_event(
|
|
session,
|
|
principal,
|
|
row=row,
|
|
event_type="record.revised",
|
|
summary=f"Record {identity.record_number} revised",
|
|
occurred_at=recorded_at,
|
|
purpose=_text(payload, "purpose"),
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_hash=request_hash,
|
|
institutional_context=row.institutional_context,
|
|
payload={
|
|
"change_reason": _text(payload, "change_reason"),
|
|
"previous_revision": current.revision,
|
|
},
|
|
)
|
|
session.flush()
|
|
return _record_dict(row, identity)
|
|
|
|
def list_records(
|
|
self,
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
query: str | None = None,
|
|
state: str | None = None,
|
|
class_id: str | None = None,
|
|
file_plan_node_id: str | None = None,
|
|
offset: int = 0,
|
|
limit: int = 100,
|
|
) -> tuple[list[dict[str, Any]], int]:
|
|
tenant_id = _tenant(principal)
|
|
statement = (
|
|
session.query(RecordRevision, RecordIdentity)
|
|
.join(RecordIdentity, RecordIdentity.id == RecordRevision.identity_id)
|
|
.filter(RecordRevision.tenant_id == tenant_id)
|
|
)
|
|
statement = apply_temporal_revision_filter(statement, RecordRevision)
|
|
if query and query.strip():
|
|
pattern = f"%{query.strip().lower()}%"
|
|
statement = statement.filter(RecordRevision.search_text.ilike(pattern))
|
|
if state:
|
|
statement = statement.filter(RecordRevision.state == state)
|
|
if class_id:
|
|
statement = statement.filter(RecordRevision.class_id == class_id)
|
|
if file_plan_node_id:
|
|
statement = statement.filter(
|
|
RecordRevision.file_plan_node_id == file_plan_node_id
|
|
)
|
|
total = statement.count()
|
|
rows = (
|
|
statement.order_by(
|
|
RecordIdentity.record_number, RecordRevision.recorded_at.desc()
|
|
)
|
|
.offset(offset)
|
|
.limit(limit)
|
|
.all()
|
|
)
|
|
return [_record_dict(row, identity) for row, identity in rows], total
|
|
|
|
def get_record(
|
|
self,
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
record_id: str,
|
|
revision: int | None = None,
|
|
) -> dict[str, Any]:
|
|
tenant_id = _tenant(principal)
|
|
query = session.query(RecordRevision).filter(
|
|
RecordRevision.tenant_id == tenant_id,
|
|
RecordRevision.record_id == record_id,
|
|
)
|
|
if revision is None:
|
|
query = apply_temporal_revision_filter(query, RecordRevision)
|
|
else:
|
|
query = query.filter(RecordRevision.revision == revision)
|
|
row = query.order_by(RecordRevision.recorded_at.desc()).first()
|
|
if row is None:
|
|
raise RecordNotFoundError(
|
|
"Record not found in the selected temporal context."
|
|
)
|
|
identity = session.get(RecordIdentity, row.identity_id)
|
|
if identity is None:
|
|
raise RecordStoreError("Record identity is missing.")
|
|
volumes = _record_volumes(session, tenant_id=tenant_id, record_id=record_id)
|
|
items = _record_items(session, tenant_id=tenant_id, record_id=record_id)
|
|
chronology = _record_chronology(
|
|
session, tenant_id=tenant_id, record_id=record_id
|
|
)
|
|
holds = _record_holds(session, tenant_id=tenant_id, record_id=record_id)
|
|
dispositions = _record_dispositions(
|
|
session, tenant_id=tenant_id, record_id=record_id
|
|
)
|
|
transfer_packages = _record_transfer_packages(
|
|
session, tenant_id=tenant_id, record_id=record_id
|
|
)
|
|
return {
|
|
"record": _record_dict(row, identity),
|
|
"volumes": [_volume_dict(item) for item in volumes],
|
|
"items": [_item_dict(item) for item in items],
|
|
"chronology": [_chronology_dict(item) for item in chronology],
|
|
"holds": [_hold_dict(item) for item in holds],
|
|
"dispositions": [_disposition_dict(item) for item in dispositions],
|
|
"transfer_packages": [
|
|
_transfer_package_dict(item) for item in transfer_packages
|
|
],
|
|
"access_explanation": {
|
|
"decision": "allowed",
|
|
"reason": "Current tenant and Records permission were evaluated for this read.",
|
|
"purpose": row.purpose,
|
|
"current_authorization": True,
|
|
"access_mode": row.access_mode,
|
|
"limitations": (
|
|
["Object-level restricted-record grants are not yet implemented."]
|
|
if row.access_mode == "restricted"
|
|
else []
|
|
),
|
|
},
|
|
}
|
|
|
|
def close_record(
|
|
self,
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
record_id: str,
|
|
payload: Mapping[str, object],
|
|
) -> dict[str, Any]:
|
|
tenant_id = _tenant(principal)
|
|
request_hash = _request_hash({"record_id": record_id, **payload})
|
|
replay = _replay_event(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_hash=request_hash,
|
|
)
|
|
if replay is not None:
|
|
return self.get_record(
|
|
session,
|
|
principal,
|
|
record_id=record_id,
|
|
revision=replay.record_revision,
|
|
)["record"]
|
|
current = _required_current_record(
|
|
session, tenant_id=tenant_id, record_id=record_id, lock=True
|
|
)
|
|
_validate_expected(
|
|
current.revision,
|
|
payload.get("expected_revision"),
|
|
label="Record",
|
|
)
|
|
if current.state != "open":
|
|
raise RecordStoreError("Only open records can be closed.")
|
|
recorded_at = _timestamp(payload.get("recorded_at"), "recorded_at")
|
|
_validate_later_revision(current.recorded_at, recorded_at)
|
|
record_class = _current_class(session, tenant_id, current.class_id)
|
|
if record_class is None:
|
|
raise RecordStoreError("The current record class is unavailable.")
|
|
restart_retention = bool(payload.get("restart_retention", False))
|
|
if current.retention_started_at is not None and not restart_retention:
|
|
retention_started_at = current.retention_started_at
|
|
retention_due_at = current.retention_due_at
|
|
retention_rule = dict(current.retention_rule)
|
|
else:
|
|
retention_started_at, retention_due_at, retention_rule = (
|
|
_calculate_retention(
|
|
record_class,
|
|
closed_at=recorded_at,
|
|
explicit_trigger_at=_optional_timestamp(
|
|
payload.get("retention_trigger_at"),
|
|
"retention_trigger_at",
|
|
),
|
|
)
|
|
)
|
|
state = "retention_running" if retention_due_at is not None else "closed"
|
|
row, identity = _revise_record_state(
|
|
session,
|
|
principal,
|
|
current=current,
|
|
recorded_at=recorded_at,
|
|
values={
|
|
"state": state,
|
|
"closed_at": recorded_at,
|
|
"retention_started_at": retention_started_at,
|
|
"retention_due_at": retention_due_at,
|
|
"retention_rule": retention_rule,
|
|
"appraisal_state": None,
|
|
"appraisal": {},
|
|
},
|
|
snapshot_patch={
|
|
"lifecycle": {
|
|
"event": "closed",
|
|
"reason": _text(payload, "reason"),
|
|
"retention_rule": retention_rule,
|
|
}
|
|
},
|
|
)
|
|
_append_event(
|
|
session,
|
|
principal,
|
|
row=row,
|
|
event_type="record.closed",
|
|
summary=f"Record {identity.record_number} closed",
|
|
occurred_at=recorded_at,
|
|
purpose=_text(payload, "purpose"),
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_hash=request_hash,
|
|
institutional_context=row.institutional_context,
|
|
payload={
|
|
"reason": _text(payload, "reason"),
|
|
"retention_started_at": _datetime_text(retention_started_at),
|
|
"retention_due_at": _datetime_text(retention_due_at),
|
|
"restart_retention": restart_retention,
|
|
},
|
|
)
|
|
session.flush()
|
|
return _record_dict(row, identity)
|
|
|
|
def reopen_record(
|
|
self,
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
record_id: str,
|
|
payload: Mapping[str, object],
|
|
) -> dict[str, Any]:
|
|
tenant_id = _tenant(principal)
|
|
request_hash = _request_hash({"record_id": record_id, **payload})
|
|
replay = _replay_event(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_hash=request_hash,
|
|
)
|
|
if replay is not None:
|
|
return self.get_record(
|
|
session,
|
|
principal,
|
|
record_id=record_id,
|
|
revision=replay.record_revision,
|
|
)["record"]
|
|
current = _required_current_record(
|
|
session, tenant_id=tenant_id, record_id=record_id, lock=True
|
|
)
|
|
_validate_expected(
|
|
current.revision,
|
|
payload.get("expected_revision"),
|
|
label="Record",
|
|
)
|
|
if current.state not in {"closed", "retention_running", "appraised"}:
|
|
raise RecordStoreError(
|
|
"Only closed, retention-running, or appraised records can be reopened."
|
|
)
|
|
if _current_disposition_for_record(session, tenant_id, record_id) is not None:
|
|
raise RecordStoreError(
|
|
"A record with a disposition proposal cannot be reopened."
|
|
)
|
|
recorded_at = _timestamp(payload.get("recorded_at"), "recorded_at")
|
|
_validate_later_revision(current.recorded_at, recorded_at)
|
|
row, identity = _revise_record_state(
|
|
session,
|
|
principal,
|
|
current=current,
|
|
recorded_at=recorded_at,
|
|
values={"state": "open", "appraisal_state": None, "appraisal": {}},
|
|
snapshot_patch={
|
|
"lifecycle": {
|
|
"event": "reopened",
|
|
"reason": _text(payload, "reason"),
|
|
}
|
|
},
|
|
)
|
|
_append_event(
|
|
session,
|
|
principal,
|
|
row=row,
|
|
event_type="record.reopened",
|
|
summary=f"Record {identity.record_number} reopened",
|
|
occurred_at=recorded_at,
|
|
purpose=_text(payload, "purpose"),
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_hash=request_hash,
|
|
institutional_context=row.institutional_context,
|
|
payload={
|
|
"reason": _text(payload, "reason"),
|
|
"retention_schedule_preserved": True,
|
|
},
|
|
)
|
|
session.flush()
|
|
return _record_dict(row, identity)
|
|
|
|
def appraise_record(
|
|
self,
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
record_id: str,
|
|
payload: Mapping[str, object],
|
|
) -> dict[str, Any]:
|
|
tenant_id = _tenant(principal)
|
|
request_hash = _request_hash({"record_id": record_id, **payload})
|
|
replay = _replay_event(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_hash=request_hash,
|
|
)
|
|
if replay is not None:
|
|
return self.get_record(
|
|
session,
|
|
principal,
|
|
record_id=record_id,
|
|
revision=replay.record_revision,
|
|
)["record"]
|
|
current = _required_current_record(
|
|
session, tenant_id=tenant_id, record_id=record_id, lock=True
|
|
)
|
|
_validate_expected(
|
|
current.revision,
|
|
payload.get("expected_revision"),
|
|
label="Record",
|
|
)
|
|
if current.state not in {"closed", "retention_running"}:
|
|
raise RecordStoreError("Only closed records can be appraised.")
|
|
recorded_at = _timestamp(payload.get("recorded_at"), "recorded_at")
|
|
_validate_later_revision(current.recorded_at, recorded_at)
|
|
override = bool(payload.get("override_retention_not_due", False))
|
|
if (
|
|
current.retention_due_at is not None
|
|
and _aware(recorded_at) < _aware(current.retention_due_at)
|
|
and not override
|
|
):
|
|
raise RecordStoreError(
|
|
"The retention period has not elapsed; an administrator override is required."
|
|
)
|
|
outcome = _text(payload, "outcome")
|
|
if outcome not in {"retain", "transfer", "destroy", "reclassify"}:
|
|
raise RecordStoreError("Unsupported appraisal outcome.")
|
|
appraisal = {
|
|
"outcome": outcome,
|
|
"reason": _text(payload, "reason"),
|
|
"policy_refs": _text_list(payload.get("policy_refs")),
|
|
"retention_not_due_override": override,
|
|
"appraised_at": _datetime_text(recorded_at),
|
|
"appraised_by": _actor(principal),
|
|
}
|
|
row, identity = _revise_record_state(
|
|
session,
|
|
principal,
|
|
current=current,
|
|
recorded_at=recorded_at,
|
|
values={
|
|
"state": "appraised",
|
|
"appraisal_state": outcome,
|
|
"appraisal": appraisal,
|
|
},
|
|
snapshot_patch={"appraisal": appraisal},
|
|
)
|
|
_append_event(
|
|
session,
|
|
principal,
|
|
row=row,
|
|
event_type="record.appraised",
|
|
summary=f"Record {identity.record_number} appraised for {outcome}",
|
|
occurred_at=recorded_at,
|
|
purpose=_text(payload, "purpose"),
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_hash=request_hash,
|
|
institutional_context=row.institutional_context,
|
|
payload=appraisal,
|
|
)
|
|
session.flush()
|
|
return _record_dict(row, identity)
|
|
|
|
def apply_hold(
|
|
self,
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
record_id: str,
|
|
payload: Mapping[str, object],
|
|
) -> dict[str, Any]:
|
|
tenant_id = _tenant(principal)
|
|
request_hash = _request_hash({"record_id": record_id, **payload})
|
|
replay = _replay_by_key(
|
|
session,
|
|
RecordHoldRevision,
|
|
tenant_id=tenant_id,
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_hash=request_hash,
|
|
)
|
|
if replay is not None:
|
|
return _hold_dict(replay)
|
|
record = _required_current_record(
|
|
session, tenant_id=tenant_id, record_id=record_id, lock=True
|
|
)
|
|
_validate_expected(
|
|
record.revision,
|
|
payload.get("expected_record_revision"),
|
|
label="Record",
|
|
)
|
|
hold_id = _optional_text(payload.get("hold_id")) or str(uuid.uuid4())
|
|
if _current_hold(session, tenant_id, hold_id) is not None:
|
|
raise RecordConflictError("A current hold already uses this identifier.")
|
|
recorded_at = _timestamp(payload.get("recorded_at"), "recorded_at")
|
|
effective_from = (
|
|
_optional_timestamp(payload.get("effective_from"), "effective_from")
|
|
or recorded_at
|
|
)
|
|
effective_to = _optional_timestamp(payload.get("effective_to"), "effective_to")
|
|
_validate_interval(effective_from, effective_to)
|
|
row = RecordHoldRevision(
|
|
tenant_id=tenant_id,
|
|
hold_id=hold_id,
|
|
record_id=record_id,
|
|
revision=1,
|
|
status="active",
|
|
reason=_text(payload, "reason"),
|
|
authority=_text(payload, "authority"),
|
|
scope=_mapping(payload.get("scope")),
|
|
effective_from=effective_from,
|
|
effective_to=effective_to,
|
|
policy_refs=_text_list(payload.get("policy_refs")),
|
|
institutional_context={
|
|
**dict(record.institutional_context),
|
|
**_mapping(payload.get("institutional_context")),
|
|
},
|
|
recorded_at=recorded_at,
|
|
changed_by=_actor(principal),
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_sha256=request_hash,
|
|
)
|
|
session.add(row)
|
|
_append_event(
|
|
session,
|
|
principal,
|
|
row=record,
|
|
event_type="record.hold_applied",
|
|
summary=f"Hold applied: {row.authority}",
|
|
occurred_at=recorded_at,
|
|
purpose=_text(payload, "purpose"),
|
|
idempotency_key=row.idempotency_key,
|
|
request_hash=request_hash,
|
|
institutional_context=row.institutional_context,
|
|
payload={
|
|
"hold_id": hold_id,
|
|
"reason": row.reason,
|
|
"authority": row.authority,
|
|
"effective_from": _datetime_text(effective_from),
|
|
"effective_to": _datetime_text(effective_to),
|
|
},
|
|
)
|
|
session.flush()
|
|
return _hold_dict(row)
|
|
|
|
def release_hold(
|
|
self,
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
record_id: str,
|
|
hold_id: str,
|
|
payload: Mapping[str, object],
|
|
) -> dict[str, Any]:
|
|
tenant_id = _tenant(principal)
|
|
request_hash = _request_hash(
|
|
{"record_id": record_id, "hold_id": hold_id, **payload}
|
|
)
|
|
replay = _replay_by_key(
|
|
session,
|
|
RecordHoldRevision,
|
|
tenant_id=tenant_id,
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_hash=request_hash,
|
|
)
|
|
if replay is not None:
|
|
return _hold_dict(replay)
|
|
record = _required_current_record(
|
|
session, tenant_id=tenant_id, record_id=record_id, lock=True
|
|
)
|
|
current = _current_hold(session, tenant_id, hold_id, lock=True)
|
|
if current is None or current.record_id != record_id:
|
|
raise RecordNotFoundError("Record hold not found.")
|
|
_validate_expected(
|
|
current.revision,
|
|
payload.get("expected_hold_revision"),
|
|
label="Record hold",
|
|
)
|
|
if current.status != "active":
|
|
raise RecordStoreError("Only an active hold can be released.")
|
|
recorded_at = _timestamp(payload.get("recorded_at"), "recorded_at")
|
|
_validate_later_revision(current.recorded_at, recorded_at)
|
|
current.superseded_at = recorded_at
|
|
row = RecordHoldRevision(
|
|
tenant_id=tenant_id,
|
|
hold_id=hold_id,
|
|
record_id=record_id,
|
|
revision=current.revision + 1,
|
|
previous_revision_id=current.id,
|
|
status="released",
|
|
reason=current.reason,
|
|
authority=current.authority,
|
|
scope={
|
|
**dict(current.scope),
|
|
"release_reason": _text(payload, "reason"),
|
|
},
|
|
effective_from=current.effective_from,
|
|
effective_to=current.effective_to or recorded_at,
|
|
released_at=recorded_at,
|
|
policy_refs=list(current.policy_refs),
|
|
institutional_context=dict(current.institutional_context),
|
|
recorded_at=recorded_at,
|
|
changed_by=_actor(principal),
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_sha256=request_hash,
|
|
)
|
|
session.add(row)
|
|
_append_event(
|
|
session,
|
|
principal,
|
|
row=record,
|
|
event_type="record.hold_released",
|
|
summary=f"Hold released: {row.authority}",
|
|
occurred_at=recorded_at,
|
|
purpose=_text(payload, "purpose"),
|
|
idempotency_key=row.idempotency_key,
|
|
request_hash=request_hash,
|
|
institutional_context=row.institutional_context,
|
|
payload={
|
|
"hold_id": hold_id,
|
|
"reason": _text(payload, "reason"),
|
|
"released_revision": row.revision,
|
|
},
|
|
)
|
|
session.flush()
|
|
return _hold_dict(row)
|
|
|
|
def propose_disposition(
|
|
self,
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
record_id: str,
|
|
payload: Mapping[str, object],
|
|
) -> dict[str, Any]:
|
|
tenant_id = _tenant(principal)
|
|
request_hash = _request_hash({"record_id": record_id, **payload})
|
|
replay = _replay_by_key(
|
|
session,
|
|
RecordDispositionRevision,
|
|
tenant_id=tenant_id,
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_hash=request_hash,
|
|
)
|
|
if replay is not None:
|
|
return _disposition_dict(replay)
|
|
record = _required_current_record(
|
|
session, tenant_id=tenant_id, record_id=record_id, lock=True
|
|
)
|
|
_validate_expected(
|
|
record.revision,
|
|
payload.get("expected_record_revision"),
|
|
label="Record",
|
|
)
|
|
if record.state != "appraised":
|
|
raise RecordStoreError(
|
|
"A disposition proposal requires an appraised record."
|
|
)
|
|
recorded_at = _timestamp(payload.get("recorded_at"), "recorded_at")
|
|
_require_no_active_hold(session, tenant_id, record_id, at=recorded_at)
|
|
if _current_disposition_for_record(session, tenant_id, record_id) is not None:
|
|
raise RecordConflictError(
|
|
"The record already has a current disposition proposal."
|
|
)
|
|
action = _text(payload, "action")
|
|
if action not in {"retain", "transfer", "destroy", "reclassify"}:
|
|
raise RecordStoreError("Unsupported disposition action.")
|
|
if record.appraisal_state != action:
|
|
raise RecordStoreError(
|
|
"The disposition action must match the current appraisal outcome."
|
|
)
|
|
disposition_id = _optional_text(payload.get("disposition_id")) or str(
|
|
uuid.uuid4()
|
|
)
|
|
evidence = _record_evidence_manifest(
|
|
session, tenant_id=tenant_id, record=record
|
|
)
|
|
subject_sha256 = _request_hash(evidence)
|
|
policy_refs = _text_list(payload.get("policy_refs"))
|
|
approval_request_id: str | None = None
|
|
status = "review_unavailable"
|
|
approval_provider = self._tenant_capability(
|
|
CAPABILITY_APPROVAL_REQUESTS, session, tenant_id
|
|
)
|
|
if isinstance(approval_provider, ApprovalRequestProvider):
|
|
approval = approval_provider.create_request(
|
|
session,
|
|
principal,
|
|
command=ApprovalRequestCreateCommand(
|
|
title=f"Disposition of record {evidence['record_number']}",
|
|
description=_text(payload, "reason"),
|
|
subject_module="records",
|
|
subject_type="record_disposition",
|
|
subject_id=disposition_id,
|
|
subject_version=str(record.revision),
|
|
subject_digest=subject_sha256,
|
|
steps=(
|
|
ApprovalStepDefinition(
|
|
key="records-disposition-review",
|
|
label="Independent disposition review",
|
|
selectors=(
|
|
ApprovalActorSelector(
|
|
kind="any_account",
|
|
value="*",
|
|
label="Authorized Records reviewer",
|
|
),
|
|
),
|
|
required_approvals=1,
|
|
forbidden_evidence_roles=("proposer",),
|
|
),
|
|
),
|
|
separation_of_duties=True,
|
|
policy_refs=tuple(policy_refs),
|
|
evidence_actors={"proposer": ((_actor(principal) or "unknown"),)},
|
|
metadata={
|
|
"record_id": record_id,
|
|
"record_revision": record.revision,
|
|
"action": action,
|
|
},
|
|
),
|
|
idempotency_key=f"records-disposition:{_text(payload, 'idempotency_key')}",
|
|
)
|
|
approval_request_id = approval.id
|
|
status = "review_pending"
|
|
consequence_preview = _disposition_consequence_preview(
|
|
action=action,
|
|
record=record,
|
|
evidence=evidence,
|
|
approvals_available=approval_request_id is not None,
|
|
)
|
|
row = RecordDispositionRevision(
|
|
tenant_id=tenant_id,
|
|
disposition_id=disposition_id,
|
|
record_id=record_id,
|
|
revision=1,
|
|
action=action,
|
|
status=status,
|
|
reason=_text(payload, "reason"),
|
|
subject_revision=record.revision,
|
|
subject_sha256=subject_sha256,
|
|
consequence_preview=consequence_preview,
|
|
policy_refs=policy_refs,
|
|
approval_request_id=approval_request_id,
|
|
proposed_by=_actor(principal),
|
|
institutional_context={
|
|
**dict(record.institutional_context),
|
|
**_mapping(payload.get("institutional_context")),
|
|
},
|
|
recorded_at=recorded_at,
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_sha256=request_hash,
|
|
)
|
|
session.add(row)
|
|
_append_event(
|
|
session,
|
|
principal,
|
|
row=record,
|
|
event_type="record.disposition_proposed",
|
|
summary=f"Disposition proposed: {action}",
|
|
occurred_at=recorded_at,
|
|
purpose=_text(payload, "purpose"),
|
|
idempotency_key=row.idempotency_key,
|
|
request_hash=request_hash,
|
|
institutional_context=row.institutional_context,
|
|
payload={
|
|
"disposition_id": disposition_id,
|
|
"action": action,
|
|
"subject_sha256": subject_sha256,
|
|
"approval_request_id": approval_request_id,
|
|
"consequence_preview": consequence_preview,
|
|
},
|
|
)
|
|
session.flush()
|
|
return _disposition_dict(row)
|
|
|
|
def finalize_disposition(
|
|
self,
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
record_id: str,
|
|
disposition_id: str,
|
|
payload: Mapping[str, object],
|
|
) -> dict[str, Any]:
|
|
tenant_id = _tenant(principal)
|
|
request_hash = _request_hash(
|
|
{"record_id": record_id, "disposition_id": disposition_id, **payload}
|
|
)
|
|
replay = _replay_by_key(
|
|
session,
|
|
RecordDispositionRevision,
|
|
tenant_id=tenant_id,
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_hash=request_hash,
|
|
)
|
|
if replay is not None:
|
|
record = _required_current_record(
|
|
session, tenant_id=tenant_id, record_id=record_id
|
|
)
|
|
identity = session.get(RecordIdentity, record.identity_id)
|
|
if identity is None:
|
|
raise RecordStoreError("Record identity is missing.")
|
|
return {
|
|
"disposition": _disposition_dict(replay),
|
|
"record": _record_dict(record, identity),
|
|
}
|
|
record = _required_current_record(
|
|
session, tenant_id=tenant_id, record_id=record_id, lock=True
|
|
)
|
|
current = _current_disposition(session, tenant_id, disposition_id, lock=True)
|
|
if current is None or current.record_id != record_id:
|
|
raise RecordNotFoundError("Record disposition not found.")
|
|
_validate_expected(
|
|
current.revision,
|
|
payload.get("expected_disposition_revision"),
|
|
label="Record disposition",
|
|
)
|
|
if current.status != "review_pending":
|
|
raise RecordStoreError(
|
|
"The disposition is not awaiting an available approval."
|
|
)
|
|
recorded_at = _timestamp(payload.get("recorded_at"), "recorded_at")
|
|
_require_no_active_hold(session, tenant_id, record_id, at=recorded_at)
|
|
evidence = _record_evidence_manifest(
|
|
session, tenant_id=tenant_id, record=record
|
|
)
|
|
if _request_hash(evidence) != current.subject_sha256:
|
|
raise RecordConflictError(
|
|
"The record evidence changed after the disposition was proposed."
|
|
)
|
|
approval_provider = self._tenant_capability(
|
|
CAPABILITY_APPROVAL_REQUESTS, session, tenant_id
|
|
)
|
|
if not isinstance(approval_provider, ApprovalRequestProvider):
|
|
raise RecordSourceUnavailableError(
|
|
"The Approvals module is required to finalize a disposition."
|
|
)
|
|
if not current.approval_request_id:
|
|
raise RecordStoreError("The disposition has no approval request.")
|
|
approval = approval_provider.check_approved(
|
|
session,
|
|
principal,
|
|
request_id=current.approval_request_id,
|
|
subject_module="records",
|
|
subject_type="record_disposition",
|
|
subject_id=disposition_id,
|
|
subject_version=str(current.subject_revision),
|
|
subject_digest=current.subject_sha256,
|
|
)
|
|
if not approval.approved:
|
|
raise RecordStoreError("The disposition has not been approved.")
|
|
current.superseded_at = recorded_at
|
|
disposition = RecordDispositionRevision(
|
|
tenant_id=tenant_id,
|
|
disposition_id=disposition_id,
|
|
record_id=record_id,
|
|
revision=current.revision + 1,
|
|
previous_revision_id=current.id,
|
|
action=current.action,
|
|
status="approved",
|
|
reason=current.reason,
|
|
subject_revision=current.subject_revision,
|
|
subject_sha256=current.subject_sha256,
|
|
consequence_preview=dict(current.consequence_preview),
|
|
policy_refs=list(current.policy_refs),
|
|
approval_request_id=current.approval_request_id,
|
|
proposed_by=current.proposed_by,
|
|
reviewed_by=_actor(principal),
|
|
reviewed_at=recorded_at,
|
|
institutional_context=dict(current.institutional_context),
|
|
recorded_at=recorded_at,
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_sha256=request_hash,
|
|
)
|
|
session.add(disposition)
|
|
state = {
|
|
"retain": "closed",
|
|
"transfer": "transfer_pending",
|
|
"destroy": "destruction_pending",
|
|
"reclassify": "open",
|
|
}[current.action]
|
|
revised, identity = _revise_record_state(
|
|
session,
|
|
principal,
|
|
current=record,
|
|
recorded_at=recorded_at,
|
|
values={"state": state},
|
|
snapshot_patch={
|
|
"disposition": {
|
|
"id": disposition_id,
|
|
"action": current.action,
|
|
"status": "approved",
|
|
"subject_sha256": current.subject_sha256,
|
|
}
|
|
},
|
|
)
|
|
_append_event(
|
|
session,
|
|
principal,
|
|
row=revised,
|
|
event_type="record.disposition_approved",
|
|
summary=f"Disposition approved: {current.action}",
|
|
occurred_at=recorded_at,
|
|
purpose=_text(payload, "purpose"),
|
|
idempotency_key=disposition.idempotency_key,
|
|
request_hash=request_hash,
|
|
institutional_context=disposition.institutional_context,
|
|
payload={
|
|
"disposition_id": disposition_id,
|
|
"approval_request_id": current.approval_request_id,
|
|
"subject_sha256": current.subject_sha256,
|
|
"record_state": state,
|
|
},
|
|
)
|
|
session.flush()
|
|
return {
|
|
"disposition": _disposition_dict(disposition),
|
|
"record": _record_dict(revised, identity),
|
|
}
|
|
|
|
def withdraw_disposition(
|
|
self,
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
record_id: str,
|
|
disposition_id: str,
|
|
payload: Mapping[str, object],
|
|
) -> dict[str, Any]:
|
|
tenant_id = _tenant(principal)
|
|
request_hash = _request_hash(
|
|
{"record_id": record_id, "disposition_id": disposition_id, **payload}
|
|
)
|
|
replay = _replay_by_key(
|
|
session,
|
|
RecordDispositionRevision,
|
|
tenant_id=tenant_id,
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_hash=request_hash,
|
|
)
|
|
if replay is not None:
|
|
return _disposition_dict(replay)
|
|
record = _required_current_record(
|
|
session, tenant_id=tenant_id, record_id=record_id, lock=True
|
|
)
|
|
current = _current_disposition(session, tenant_id, disposition_id, lock=True)
|
|
if current is None or current.record_id != record_id:
|
|
raise RecordNotFoundError("Record disposition not found.")
|
|
_validate_expected(
|
|
current.revision,
|
|
payload.get("expected_disposition_revision"),
|
|
label="Record disposition",
|
|
)
|
|
if current.status not in {"review_pending", "review_unavailable"}:
|
|
raise RecordStoreError(
|
|
"Only an unapproved disposition proposal can be withdrawn."
|
|
)
|
|
recorded_at = _timestamp(payload.get("recorded_at"), "recorded_at")
|
|
_validate_later_revision(current.recorded_at, recorded_at)
|
|
current.superseded_at = recorded_at
|
|
row = RecordDispositionRevision(
|
|
tenant_id=tenant_id,
|
|
disposition_id=disposition_id,
|
|
record_id=record_id,
|
|
revision=current.revision + 1,
|
|
previous_revision_id=current.id,
|
|
action=current.action,
|
|
status="withdrawn",
|
|
reason=current.reason,
|
|
subject_revision=current.subject_revision,
|
|
subject_sha256=current.subject_sha256,
|
|
consequence_preview={
|
|
**dict(current.consequence_preview),
|
|
"withdrawal_reason": _text(payload, "reason"),
|
|
},
|
|
policy_refs=list(current.policy_refs),
|
|
approval_request_id=current.approval_request_id,
|
|
proposed_by=current.proposed_by,
|
|
reviewed_by=_actor(principal),
|
|
reviewed_at=recorded_at,
|
|
institutional_context=dict(current.institutional_context),
|
|
recorded_at=recorded_at,
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_sha256=request_hash,
|
|
)
|
|
session.add(row)
|
|
_append_event(
|
|
session,
|
|
principal,
|
|
row=record,
|
|
event_type="record.disposition_withdrawn",
|
|
summary=f"Disposition withdrawn: {current.action}",
|
|
occurred_at=recorded_at,
|
|
purpose=_text(payload, "purpose"),
|
|
idempotency_key=row.idempotency_key,
|
|
request_hash=request_hash,
|
|
institutional_context=row.institutional_context,
|
|
payload={
|
|
"disposition_id": disposition_id,
|
|
"reason": _text(payload, "reason"),
|
|
"superseded_revision": current.revision,
|
|
},
|
|
)
|
|
session.flush()
|
|
return _disposition_dict(row)
|
|
|
|
def archive_providers(
|
|
self, session: Session, principal: object
|
|
) -> list[dict[str, Any]]:
|
|
tenant_id = _tenant(principal)
|
|
providers: list[dict[str, Any]] = []
|
|
for capability_name in record_archive_capabilities(self.registry):
|
|
provider = self._tenant_capability(capability_name, session, tenant_id)
|
|
if not isinstance(provider, RecordArchiveProvider):
|
|
continue
|
|
state = provider.state()
|
|
providers.append(
|
|
{
|
|
"id": state.provider_id,
|
|
"label": state.label,
|
|
"profiles": list(state.profiles),
|
|
"authority_modes": list(state.authority_modes),
|
|
"healthy": state.healthy,
|
|
"checked_at": _datetime_text(state.checked_at),
|
|
"last_success_at": _datetime_text(state.last_success_at),
|
|
"freshness_seconds": state.freshness_seconds,
|
|
"limitations": list(state.limitations),
|
|
"simulated": state.simulated,
|
|
}
|
|
)
|
|
return providers
|
|
|
|
def recovery_status(
|
|
self,
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
record_id: str,
|
|
) -> dict[str, Any]:
|
|
tenant_id = _tenant(principal)
|
|
record = _required_current_record(
|
|
session, tenant_id=tenant_id, record_id=record_id
|
|
)
|
|
identity = session.get(RecordIdentity, record.identity_id)
|
|
if identity is None:
|
|
raise RecordStoreError("Record identity is missing.")
|
|
source_checks: list[dict[str, Any]] = []
|
|
for item in (
|
|
session.query(RecordItem)
|
|
.filter(
|
|
RecordItem.tenant_id == tenant_id,
|
|
RecordItem.record_id == record_id,
|
|
)
|
|
.order_by(RecordItem.sequence)
|
|
.all()
|
|
):
|
|
check = {
|
|
"item_id": item.id,
|
|
"source_module": item.source_module,
|
|
"resource_type": item.resource_type,
|
|
"resource_id": item.resource_id,
|
|
"source_revision": item.source_revision,
|
|
"stored_content_sha256": item.content_sha256,
|
|
"status": "unavailable",
|
|
"error": None,
|
|
}
|
|
provider = self._tenant_capability(
|
|
record_source_capability(item.source_module), session, tenant_id
|
|
)
|
|
if not isinstance(provider, RecordSourceProvider):
|
|
check["error"] = "The source provider is not enabled."
|
|
source_checks.append(check)
|
|
continue
|
|
try:
|
|
reference = provider.resolve(
|
|
session,
|
|
principal,
|
|
locator=RecordSourceLocator(
|
|
tenant_id=tenant_id,
|
|
source_module=item.source_module,
|
|
resource_type=item.resource_type,
|
|
resource_id=item.resource_id,
|
|
source_revision=item.source_revision,
|
|
metadata=dict(item.source_metadata),
|
|
),
|
|
purpose=item.purpose,
|
|
)
|
|
except Exception as exc:
|
|
check["error"] = f"Source revalidation failed ({type(exc).__name__})."
|
|
source_checks.append(check)
|
|
continue
|
|
resolved_digest = (reference.content_sha256 or "").removeprefix(
|
|
"sha256:"
|
|
) or None
|
|
check["resolved_content_sha256"] = resolved_digest
|
|
if item.content_sha256 and resolved_digest != item.content_sha256:
|
|
check["status"] = "digest_mismatch"
|
|
check["error"] = (
|
|
"The source digest no longer matches the filed evidence."
|
|
)
|
|
else:
|
|
check["status"] = "verified"
|
|
source_checks.append(check)
|
|
|
|
package_checks = []
|
|
for package in _record_transfer_packages(
|
|
session, tenant_id=tenant_id, record_id=record_id
|
|
):
|
|
calculated = _request_hash(dict(package.manifest))
|
|
package_checks.append(
|
|
{
|
|
"package_id": package.package_id,
|
|
"revision": package.revision,
|
|
"status": package.status,
|
|
"simulated": package.simulated,
|
|
"stored_manifest_sha256": package.manifest_sha256,
|
|
"calculated_manifest_sha256": calculated,
|
|
"manifest_verified": calculated == package.manifest_sha256,
|
|
"receipt_sha256": package.receipt_sha256,
|
|
"recovery_operation_id": package.recovery_operation_id,
|
|
}
|
|
)
|
|
|
|
operations = (
|
|
session.query(RecoveryOperation)
|
|
.filter(
|
|
RecoveryOperation.module_id == "records",
|
|
RecoveryOperation.resource_type == "record",
|
|
RecoveryOperation.resource_id.in_((record_id, identity.record_number)),
|
|
)
|
|
.order_by(RecoveryOperation.created_at)
|
|
.all()
|
|
)
|
|
operation_checks = [
|
|
{
|
|
"operation_id": operation.id,
|
|
"operation_type": operation.operation_type,
|
|
"status": operation.status,
|
|
"mode": operation.mode,
|
|
"checkpoint_count": operation.checkpoint_count,
|
|
"evidence_head_sha256": operation.evidence_head_sha256,
|
|
"evidence_chain_verified": verify_recovery_evidence_chain(
|
|
session, operation.id
|
|
),
|
|
"completed_at": _datetime_text(operation.completed_at),
|
|
}
|
|
for operation in operations
|
|
]
|
|
evidence = _record_evidence_manifest(
|
|
session, tenant_id=tenant_id, record=record
|
|
)
|
|
failures = [
|
|
check for check in source_checks if check["status"] not in {"verified"}
|
|
]
|
|
failures.extend(
|
|
check for check in package_checks if not check["manifest_verified"]
|
|
)
|
|
failures.extend(
|
|
check for check in operation_checks if not check["evidence_chain_verified"]
|
|
)
|
|
return {
|
|
"record_id": record_id,
|
|
"record_revision": record.revision,
|
|
"evidence_sha256": _request_hash(evidence),
|
|
"healthy": not failures,
|
|
"source_checks": source_checks,
|
|
"package_checks": package_checks,
|
|
"recovery_operations": operation_checks,
|
|
"failure_count": len(failures),
|
|
"limitations": [
|
|
"Source checks use current authorization and never bypass the owning module.",
|
|
"A verified simulation receipt does not prove archival custody.",
|
|
],
|
|
}
|
|
|
|
def prepare_transfer_package(
|
|
self,
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
record_id: str,
|
|
payload: Mapping[str, object],
|
|
) -> dict[str, Any]:
|
|
tenant_id = _tenant(principal)
|
|
request_hash = _request_hash({"record_id": record_id, **payload})
|
|
replay = _replay_by_key(
|
|
session,
|
|
RecordTransferPackageRevision,
|
|
tenant_id=tenant_id,
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_hash=request_hash,
|
|
)
|
|
if replay is not None:
|
|
return _transfer_package_dict(replay)
|
|
record = _required_current_record(
|
|
session, tenant_id=tenant_id, record_id=record_id, lock=True
|
|
)
|
|
_validate_expected(
|
|
record.revision,
|
|
payload.get("expected_record_revision"),
|
|
label="Record",
|
|
)
|
|
recorded_at = _timestamp(payload.get("recorded_at"), "recorded_at")
|
|
_require_no_active_hold(session, tenant_id, record_id, at=recorded_at)
|
|
disposition_id = _text(payload, "disposition_id")
|
|
disposition = _current_disposition(session, tenant_id, disposition_id)
|
|
if (
|
|
disposition is None
|
|
or disposition.record_id != record_id
|
|
or disposition.action != "transfer"
|
|
or disposition.status != "approved"
|
|
):
|
|
raise RecordStoreError(
|
|
"An approved transfer disposition is required before packaging."
|
|
)
|
|
provider_id = _text(payload, "provider_id")
|
|
provider = self._tenant_capability(
|
|
record_archive_capability(provider_id), session, tenant_id
|
|
)
|
|
if not isinstance(provider, RecordArchiveProvider):
|
|
raise RecordSourceUnavailableError(
|
|
"The selected record archive provider is unavailable."
|
|
)
|
|
provider_state = provider.state()
|
|
profile = _text(payload, "profile")
|
|
if not provider_state.healthy or profile not in provider_state.profiles:
|
|
raise RecordSourceUnavailableError(
|
|
"The selected archive profile is unavailable or unhealthy."
|
|
)
|
|
package_id = _optional_text(payload.get("package_id")) or str(uuid.uuid4())
|
|
manifest = {
|
|
"format": "govoplan-record-transfer-manifest",
|
|
"version": 1,
|
|
"package_id": package_id,
|
|
"provider_id": provider_id,
|
|
"profile": profile,
|
|
"prepared_at": _datetime_text(recorded_at),
|
|
"record": _record_evidence_manifest(
|
|
session, tenant_id=tenant_id, record=record
|
|
),
|
|
"disposition": _disposition_dict(disposition),
|
|
"institutional_context": dict(record.institutional_context),
|
|
}
|
|
manifest_sha256 = _request_hash(manifest)
|
|
row = RecordTransferPackageRevision(
|
|
tenant_id=tenant_id,
|
|
package_id=package_id,
|
|
record_id=record_id,
|
|
disposition_id=disposition_id,
|
|
revision=1,
|
|
record_revision=record.revision,
|
|
provider_id=provider_id,
|
|
profile=profile,
|
|
status="prepared",
|
|
authority_mode=provider_state.authority_modes[0],
|
|
manifest=manifest,
|
|
manifest_sha256=manifest_sha256,
|
|
receipt={},
|
|
recovery_operation_id=current_record_recovery_operation(),
|
|
simulated=provider_state.simulated,
|
|
institutional_context=dict(record.institutional_context),
|
|
recorded_at=recorded_at,
|
|
changed_by=_actor(principal),
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_sha256=request_hash,
|
|
)
|
|
session.add(row)
|
|
_append_event(
|
|
session,
|
|
principal,
|
|
row=record,
|
|
event_type="record.transfer_package_prepared",
|
|
summary=f"Transfer package prepared for {provider_state.label}",
|
|
occurred_at=recorded_at,
|
|
purpose=_text(payload, "purpose"),
|
|
idempotency_key=row.idempotency_key,
|
|
request_hash=request_hash,
|
|
institutional_context=row.institutional_context,
|
|
payload={
|
|
"package_id": package_id,
|
|
"provider_id": provider_id,
|
|
"profile": profile,
|
|
"manifest_sha256": manifest_sha256,
|
|
"simulated": provider_state.simulated,
|
|
},
|
|
)
|
|
session.flush()
|
|
return _transfer_package_dict(row)
|
|
|
|
def dispatch_transfer_package(
|
|
self,
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
record_id: str,
|
|
package_id: str,
|
|
payload: Mapping[str, object],
|
|
) -> dict[str, Any]:
|
|
tenant_id = _tenant(principal)
|
|
request_hash = _request_hash(
|
|
{"record_id": record_id, "package_id": package_id, **payload}
|
|
)
|
|
replay = _replay_by_key(
|
|
session,
|
|
RecordTransferPackageRevision,
|
|
tenant_id=tenant_id,
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_hash=request_hash,
|
|
)
|
|
if replay is not None:
|
|
return _transfer_package_dict(replay)
|
|
record = _required_current_record(
|
|
session, tenant_id=tenant_id, record_id=record_id, lock=True
|
|
)
|
|
current = _current_transfer_package(session, tenant_id, package_id, lock=True)
|
|
if current is None or current.record_id != record_id:
|
|
raise RecordNotFoundError("Record transfer package not found.")
|
|
_validate_expected(
|
|
current.revision,
|
|
payload.get("expected_package_revision"),
|
|
label="Record transfer package",
|
|
)
|
|
if current.status != "prepared":
|
|
raise RecordStoreError(
|
|
"Only a prepared package can be dispatched; unknown outcomes require reconciliation."
|
|
)
|
|
recorded_at = _timestamp(payload.get("recorded_at"), "recorded_at")
|
|
_require_no_active_hold(session, tenant_id, record_id, at=recorded_at)
|
|
provider = self._tenant_capability(
|
|
record_archive_capability(current.provider_id), session, tenant_id
|
|
)
|
|
if not isinstance(provider, RecordArchiveProvider):
|
|
raise RecordSourceUnavailableError(
|
|
"The record archive provider is unavailable."
|
|
)
|
|
if not provider.state().simulated:
|
|
raise RecordSourceUnavailableError(
|
|
"Real archive dispatch requires a configured target-specific recovery profile."
|
|
)
|
|
try:
|
|
receipt = provider.dispatch(
|
|
session,
|
|
principal,
|
|
request=RecordArchiveTransferRequest(
|
|
package=RecordTransferPackage(
|
|
tenant_id=tenant_id,
|
|
package_id=package_id,
|
|
record_id=record_id,
|
|
record_revision=current.record_revision,
|
|
profile=current.profile,
|
|
manifest_sha256=current.manifest_sha256,
|
|
manifest=dict(current.manifest),
|
|
),
|
|
purpose=_text(payload, "purpose"),
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
institutional_context=dict(current.institutional_context),
|
|
),
|
|
)
|
|
except RecordContractError as exc:
|
|
raise RecordStoreError(str(exc)) from exc
|
|
if (
|
|
receipt.package_id != package_id
|
|
or receipt.provider_id != current.provider_id
|
|
):
|
|
raise RecordStoreError(
|
|
"The archive provider returned a receipt for another package."
|
|
)
|
|
if not receipt.simulated:
|
|
raise RecordStoreError(
|
|
"The simulation provider returned a receipt that could be mistaken for a real custody transfer."
|
|
)
|
|
current.superseded_at = recorded_at
|
|
status = {
|
|
"accepted": ("simulated_accepted" if receipt.simulated else "accepted"),
|
|
"rejected": "rejected",
|
|
"outcome_unknown": "outcome_unknown",
|
|
}[receipt.outcome]
|
|
receipt_data = {
|
|
"provider_id": receipt.provider_id,
|
|
"package_id": receipt.package_id,
|
|
"outcome": receipt.outcome,
|
|
"observed_at": _datetime_text(receipt.observed_at),
|
|
"external_reference": receipt.external_reference,
|
|
"retry_safe": receipt.retry_safe,
|
|
"simulated": receipt.simulated,
|
|
"metadata": dict(receipt.metadata),
|
|
}
|
|
row = RecordTransferPackageRevision(
|
|
tenant_id=tenant_id,
|
|
package_id=package_id,
|
|
record_id=record_id,
|
|
disposition_id=current.disposition_id,
|
|
revision=current.revision + 1,
|
|
previous_revision_id=current.id,
|
|
record_revision=current.record_revision,
|
|
provider_id=current.provider_id,
|
|
profile=current.profile,
|
|
status=status,
|
|
authority_mode=current.authority_mode,
|
|
manifest=dict(current.manifest),
|
|
manifest_sha256=current.manifest_sha256,
|
|
receipt=receipt_data,
|
|
receipt_sha256=receipt.receipt_sha256.removeprefix("sha256:"),
|
|
external_reference=receipt.external_reference,
|
|
recovery_operation_id=current_record_recovery_operation()
|
|
or current.recovery_operation_id,
|
|
simulated=receipt.simulated,
|
|
institutional_context=dict(current.institutional_context),
|
|
recorded_at=recorded_at,
|
|
changed_by=_actor(principal),
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_sha256=request_hash,
|
|
)
|
|
session.add(row)
|
|
_append_event(
|
|
session,
|
|
principal,
|
|
row=record,
|
|
event_type="record.transfer_package_dispatched",
|
|
summary=f"Transfer package result: {status}",
|
|
occurred_at=recorded_at,
|
|
purpose=_text(payload, "purpose"),
|
|
idempotency_key=row.idempotency_key,
|
|
request_hash=request_hash,
|
|
institutional_context=row.institutional_context,
|
|
payload={
|
|
"package_id": package_id,
|
|
"provider_id": current.provider_id,
|
|
"status": status,
|
|
"receipt_sha256": row.receipt_sha256,
|
|
"simulated": row.simulated,
|
|
"external_reference": row.external_reference,
|
|
},
|
|
)
|
|
session.flush()
|
|
return _transfer_package_dict(row)
|
|
|
|
def create_volume(
|
|
self,
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
record_id: str,
|
|
payload: Mapping[str, object],
|
|
) -> dict[str, Any]:
|
|
tenant_id = _tenant(principal)
|
|
request_hash = _request_hash({"record_id": record_id, **payload})
|
|
replay = _replay_event(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_hash=request_hash,
|
|
)
|
|
if replay is not None:
|
|
volume_id = str(replay.payload.get("volume_id") or "")
|
|
row = _current_volume(session, tenant_id, volume_id)
|
|
if row is None:
|
|
raise RecordStoreError("Replayed record volume is missing.")
|
|
return _volume_dict(row)
|
|
record = _current_record(session, tenant_id, record_id, lock=True)
|
|
if record is None:
|
|
raise RecordNotFoundError("Record not found.")
|
|
sequence = (
|
|
int(
|
|
session.query(func.max(RecordVolumeRevision.sequence))
|
|
.filter(
|
|
RecordVolumeRevision.tenant_id == tenant_id,
|
|
RecordVolumeRevision.record_id == record_id,
|
|
RecordVolumeRevision.superseded_at.is_(None),
|
|
)
|
|
.scalar()
|
|
or 0
|
|
)
|
|
+ 1
|
|
)
|
|
recorded_at = _timestamp(payload.get("recorded_at"), "recorded_at")
|
|
row = RecordVolumeRevision(
|
|
tenant_id=tenant_id,
|
|
volume_id=_optional_text(payload.get("volume_id")) or str(uuid.uuid4()),
|
|
record_id=record_id,
|
|
revision=1,
|
|
sequence=sequence,
|
|
label=_text(payload, "label"),
|
|
state="open",
|
|
valid_from=_optional_timestamp(payload.get("valid_from"), "valid_from")
|
|
or recorded_at,
|
|
valid_to=_optional_timestamp(payload.get("valid_to"), "valid_to"),
|
|
recorded_at=recorded_at,
|
|
changed_by=_actor(principal),
|
|
)
|
|
_validate_interval(row.valid_from, row.valid_to)
|
|
session.add(row)
|
|
_append_event(
|
|
session,
|
|
principal,
|
|
row=record,
|
|
event_type="record.volume_created",
|
|
summary=f"Volume {row.label} created",
|
|
occurred_at=recorded_at,
|
|
purpose=_text(payload, "purpose"),
|
|
idempotency_key=_text(payload, "idempotency_key"),
|
|
request_hash=request_hash,
|
|
institutional_context=record.institutional_context,
|
|
payload={"volume_id": row.volume_id, "sequence": sequence},
|
|
)
|
|
session.flush()
|
|
return _volume_dict(row)
|
|
|
|
def file(
|
|
self,
|
|
session: object,
|
|
principal: object,
|
|
*,
|
|
request: RecordFilingRequest,
|
|
) -> RecordFilingResult:
|
|
if not isinstance(session, Session):
|
|
raise RecordStoreError("Record filing requires a database session.")
|
|
tenant_id = _tenant(principal)
|
|
if request.tenant_id != tenant_id:
|
|
raise RecordStoreError("Record filing cannot cross tenants.")
|
|
request_hash = _request_hash(_filing_request_mapping(request))
|
|
replay = (
|
|
session.query(RecordItem)
|
|
.filter(
|
|
RecordItem.tenant_id == tenant_id,
|
|
RecordItem.idempotency_key == request.idempotency_key,
|
|
)
|
|
.one_or_none()
|
|
)
|
|
if replay is not None:
|
|
_verify_replay(replay.request_sha256, request_hash)
|
|
return _filing_result(replay, replayed=True)
|
|
record = _current_record(session, tenant_id, request.record_id, lock=True)
|
|
if record is None:
|
|
raise RecordNotFoundError("Record not found.")
|
|
if record.state != "open":
|
|
raise RecordStoreError("Only open records accept new items.")
|
|
record_class = _current_class(session, tenant_id, record.class_id)
|
|
if record_class is None:
|
|
raise RecordStoreError("The current record class is unavailable.")
|
|
if request.volume_id:
|
|
volume = _current_volume(session, tenant_id, request.volume_id)
|
|
if volume is None or volume.record_id != request.record_id:
|
|
raise RecordStoreError(
|
|
"The selected record volume does not belong to this record."
|
|
)
|
|
reference = self._resolve_source(session, principal, request=request)
|
|
source_key = (
|
|
f"{reference.locator.source_module}:{reference.locator.resource_type}"
|
|
)
|
|
if record_class.allowed_source_types and (
|
|
source_key not in record_class.allowed_source_types
|
|
and reference.locator.resource_type not in record_class.allowed_source_types
|
|
):
|
|
raise RecordStoreError("The record class does not permit this source type.")
|
|
sequence = (
|
|
int(
|
|
session.query(func.max(RecordItem.sequence))
|
|
.filter(
|
|
RecordItem.tenant_id == tenant_id,
|
|
RecordItem.record_id == request.record_id,
|
|
)
|
|
.scalar()
|
|
or 0
|
|
)
|
|
+ 1
|
|
)
|
|
filed_at = datetime.now(UTC)
|
|
row = RecordItem(
|
|
tenant_id=tenant_id,
|
|
record_id=request.record_id,
|
|
volume_id=request.volume_id,
|
|
sequence=sequence,
|
|
source_module=reference.locator.source_module,
|
|
resource_type=reference.locator.resource_type,
|
|
resource_id=reference.locator.resource_id,
|
|
source_revision=reference.locator.source_revision,
|
|
label=reference.label,
|
|
relationship=request.relationship,
|
|
filing_reason=request.filing_reason,
|
|
purpose=request.purpose,
|
|
authority_mode=reference.authority_mode,
|
|
content_sha256=(reference.content_sha256 or "").removeprefix("sha256:")
|
|
or None,
|
|
content_type=reference.content_type,
|
|
size_bytes=reference.size_bytes,
|
|
source_valid_from=reference.valid_from,
|
|
source_valid_to=reference.valid_to,
|
|
source_recorded_at=reference.recorded_at,
|
|
launch_url=reference.launch_url,
|
|
filed_at=filed_at,
|
|
filed_by=_actor(principal),
|
|
actor_assignment_id=_actor_assignment(principal),
|
|
actor_delegation_id=_actor_delegation(principal),
|
|
institutional_context=dict(request.institutional_context),
|
|
source_metadata=dict(reference.metadata),
|
|
filing_metadata=dict(request.metadata),
|
|
idempotency_key=request.idempotency_key,
|
|
request_sha256=request_hash,
|
|
)
|
|
session.add(row)
|
|
session.flush()
|
|
_append_event(
|
|
session,
|
|
principal,
|
|
row=record,
|
|
event_type="record.item_filed",
|
|
summary=f"{reference.label} filed",
|
|
occurred_at=filed_at,
|
|
purpose=request.purpose,
|
|
idempotency_key=request.idempotency_key,
|
|
request_hash=request_hash,
|
|
institutional_context=dict(request.institutional_context),
|
|
payload={
|
|
"item_id": row.id,
|
|
"sequence": sequence,
|
|
"source_module": row.source_module,
|
|
"resource_type": row.resource_type,
|
|
"resource_id": row.resource_id,
|
|
"source_revision": row.source_revision,
|
|
},
|
|
)
|
|
session.flush()
|
|
return _filing_result(row, replayed=False)
|
|
|
|
def source_providers(
|
|
self,
|
|
session: Session,
|
|
principal: object,
|
|
) -> list[dict[str, object]]:
|
|
tenant_id = _tenant(principal)
|
|
providers: list[dict[str, object]] = []
|
|
for capability_name in record_source_capabilities(self.registry):
|
|
provider = self._tenant_capability(capability_name, session, tenant_id)
|
|
if not isinstance(provider, RecordSourceProvider):
|
|
continue
|
|
providers.append(
|
|
{
|
|
"id": provider.provider_id,
|
|
"source_module": capability_name.removeprefix("records.source."),
|
|
"resource_types": list(provider.resource_types()),
|
|
}
|
|
)
|
|
return providers
|
|
|
|
def _resolve_source(
|
|
self,
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
request: RecordFilingRequest,
|
|
) -> RecordSourceReference:
|
|
capability_name = record_source_capability(request.source.source_module)
|
|
provider = self._tenant_capability(capability_name, session, request.tenant_id)
|
|
if not isinstance(provider, RecordSourceProvider):
|
|
raise RecordSourceUnavailableError(
|
|
f"No enabled record source provider is available for {request.source.source_module}."
|
|
)
|
|
if request.source.resource_type not in provider.resource_types():
|
|
raise RecordStoreError(
|
|
"The record source provider does not support this resource type."
|
|
)
|
|
try:
|
|
reference = provider.resolve(
|
|
session,
|
|
principal,
|
|
locator=request.source,
|
|
purpose=request.purpose,
|
|
)
|
|
except RecordContractError as exc:
|
|
raise RecordStoreError(str(exc)) from exc
|
|
if reference.locator != request.source:
|
|
raise RecordStoreError(
|
|
"The source provider returned a different source revision."
|
|
)
|
|
return reference
|
|
|
|
def _tenant_capability(
|
|
self, name: str, session: Session, tenant_id: str
|
|
) -> object | None:
|
|
if self.registry is None:
|
|
return None
|
|
try:
|
|
if hasattr(self.registry, "tenant_capability"):
|
|
return self.registry.tenant_capability(
|
|
name, session, tenant_id=tenant_id
|
|
)
|
|
if hasattr(self.registry, "capability"):
|
|
return self.registry.capability(name)
|
|
except Exception as exc:
|
|
raise RecordSourceUnavailableError(
|
|
f"Record capability {name} is unavailable: {exc}"
|
|
) from exc
|
|
return None
|
|
|
|
|
|
def _current_file_plan(
|
|
session: Session, tenant_id: str, node_id: str, *, lock: bool = False
|
|
) -> RecordFilePlanRevision | None:
|
|
query = session.query(RecordFilePlanRevision).filter(
|
|
RecordFilePlanRevision.tenant_id == tenant_id,
|
|
RecordFilePlanRevision.node_id == node_id,
|
|
RecordFilePlanRevision.superseded_at.is_(None),
|
|
)
|
|
return query.with_for_update().one_or_none() if lock else query.one_or_none()
|
|
|
|
|
|
def _validate_file_plan_parent(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
node_id: str,
|
|
parent_node_id: str | None,
|
|
) -> None:
|
|
current_id = parent_node_id
|
|
visited: set[str] = set()
|
|
while current_id is not None:
|
|
if current_id == node_id or current_id in visited:
|
|
raise RecordStoreError("The file-plan parent would create a cycle.")
|
|
visited.add(current_id)
|
|
parent = _current_file_plan(session, tenant_id, current_id)
|
|
if parent is None:
|
|
raise RecordStoreError("The parent file-plan node does not exist.")
|
|
current_id = parent.parent_node_id
|
|
|
|
|
|
def _current_class(
|
|
session: Session, tenant_id: str, class_id: str, *, lock: bool = False
|
|
) -> RecordClassRevision | None:
|
|
query = session.query(RecordClassRevision).filter(
|
|
RecordClassRevision.tenant_id == tenant_id,
|
|
RecordClassRevision.class_id == class_id,
|
|
RecordClassRevision.superseded_at.is_(None),
|
|
)
|
|
return query.with_for_update().one_or_none() if lock else query.one_or_none()
|
|
|
|
|
|
def _current_record(
|
|
session: Session, tenant_id: str, record_id: str, *, lock: bool = False
|
|
) -> RecordRevision | None:
|
|
query = session.query(RecordRevision).filter(
|
|
RecordRevision.tenant_id == tenant_id,
|
|
RecordRevision.record_id == record_id,
|
|
RecordRevision.superseded_at.is_(None),
|
|
)
|
|
return query.with_for_update().one_or_none() if lock else query.one_or_none()
|
|
|
|
|
|
def _required_current_record(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
record_id: str,
|
|
lock: bool = False,
|
|
) -> RecordRevision:
|
|
row = _current_record(session, tenant_id, record_id, lock=lock)
|
|
if row is None:
|
|
raise RecordNotFoundError("Record not found.")
|
|
return row
|
|
|
|
|
|
def _current_hold(
|
|
session: Session,
|
|
tenant_id: str,
|
|
hold_id: str,
|
|
*,
|
|
lock: bool = False,
|
|
) -> RecordHoldRevision | None:
|
|
query = session.query(RecordHoldRevision).filter(
|
|
RecordHoldRevision.tenant_id == tenant_id,
|
|
RecordHoldRevision.hold_id == hold_id,
|
|
RecordHoldRevision.superseded_at.is_(None),
|
|
)
|
|
return query.with_for_update().one_or_none() if lock else query.one_or_none()
|
|
|
|
|
|
def _current_disposition(
|
|
session: Session,
|
|
tenant_id: str,
|
|
disposition_id: str,
|
|
*,
|
|
lock: bool = False,
|
|
) -> RecordDispositionRevision | None:
|
|
query = session.query(RecordDispositionRevision).filter(
|
|
RecordDispositionRevision.tenant_id == tenant_id,
|
|
RecordDispositionRevision.disposition_id == disposition_id,
|
|
RecordDispositionRevision.superseded_at.is_(None),
|
|
)
|
|
return query.with_for_update().one_or_none() if lock else query.one_or_none()
|
|
|
|
|
|
def _current_disposition_for_record(
|
|
session: Session, tenant_id: str, record_id: str
|
|
) -> RecordDispositionRevision | None:
|
|
return (
|
|
session.query(RecordDispositionRevision)
|
|
.filter(
|
|
RecordDispositionRevision.tenant_id == tenant_id,
|
|
RecordDispositionRevision.record_id == record_id,
|
|
RecordDispositionRevision.superseded_at.is_(None),
|
|
RecordDispositionRevision.status != "withdrawn",
|
|
)
|
|
.one_or_none()
|
|
)
|
|
|
|
|
|
def _current_transfer_package(
|
|
session: Session,
|
|
tenant_id: str,
|
|
package_id: str,
|
|
*,
|
|
lock: bool = False,
|
|
) -> RecordTransferPackageRevision | None:
|
|
query = session.query(RecordTransferPackageRevision).filter(
|
|
RecordTransferPackageRevision.tenant_id == tenant_id,
|
|
RecordTransferPackageRevision.package_id == package_id,
|
|
RecordTransferPackageRevision.superseded_at.is_(None),
|
|
)
|
|
return query.with_for_update().one_or_none() if lock else query.one_or_none()
|
|
|
|
|
|
def _current_volume(
|
|
session: Session, tenant_id: str, volume_id: str
|
|
) -> RecordVolumeRevision | None:
|
|
return (
|
|
session.query(RecordVolumeRevision)
|
|
.filter(
|
|
RecordVolumeRevision.tenant_id == tenant_id,
|
|
RecordVolumeRevision.volume_id == volume_id,
|
|
RecordVolumeRevision.superseded_at.is_(None),
|
|
)
|
|
.one_or_none()
|
|
)
|
|
|
|
|
|
def _record_volumes(
|
|
session: Session, *, tenant_id: str, record_id: str
|
|
) -> Sequence[RecordVolumeRevision]:
|
|
query = session.query(RecordVolumeRevision).filter(
|
|
RecordVolumeRevision.tenant_id == tenant_id,
|
|
RecordVolumeRevision.record_id == record_id,
|
|
)
|
|
return (
|
|
apply_temporal_revision_filter(query, RecordVolumeRevision)
|
|
.order_by(RecordVolumeRevision.sequence)
|
|
.all()
|
|
)
|
|
|
|
|
|
def _record_items(
|
|
session: Session, *, tenant_id: str, record_id: str
|
|
) -> Sequence[RecordItem]:
|
|
context = current_temporal_data_context()
|
|
query = session.query(RecordItem).filter(
|
|
RecordItem.tenant_id == tenant_id,
|
|
RecordItem.record_id == record_id,
|
|
)
|
|
if context.recorded_at is not None:
|
|
query = query.filter(RecordItem.filed_at <= context.recorded_at)
|
|
instant = context.validity_instant
|
|
if instant is not None:
|
|
query = query.filter(
|
|
or_(
|
|
RecordItem.source_valid_from.is_(None),
|
|
RecordItem.source_valid_from <= instant,
|
|
),
|
|
or_(
|
|
RecordItem.source_valid_to.is_(None),
|
|
RecordItem.source_valid_to > instant,
|
|
),
|
|
)
|
|
return query.order_by(RecordItem.sequence).all()
|
|
|
|
|
|
def _record_chronology(
|
|
session: Session, *, tenant_id: str, record_id: str
|
|
) -> Sequence[RecordChronologyEntry]:
|
|
context = current_temporal_data_context()
|
|
query = session.query(RecordChronologyEntry).filter(
|
|
RecordChronologyEntry.tenant_id == tenant_id,
|
|
RecordChronologyEntry.record_id == record_id,
|
|
)
|
|
if context.recorded_at is not None:
|
|
query = query.filter(RecordChronologyEntry.occurred_at <= context.recorded_at)
|
|
return query.order_by(RecordChronologyEntry.occurred_at.desc()).all()
|
|
|
|
|
|
def _record_holds(
|
|
session: Session, *, tenant_id: str, record_id: str
|
|
) -> Sequence[RecordHoldRevision]:
|
|
query = session.query(RecordHoldRevision).filter(
|
|
RecordHoldRevision.tenant_id == tenant_id,
|
|
RecordHoldRevision.record_id == record_id,
|
|
)
|
|
return (
|
|
apply_temporal_revision_filter(
|
|
query,
|
|
RecordHoldRevision,
|
|
valid_from=None,
|
|
valid_to=None,
|
|
)
|
|
.order_by(RecordHoldRevision.recorded_at.desc())
|
|
.all()
|
|
)
|
|
|
|
|
|
def _record_dispositions(
|
|
session: Session, *, tenant_id: str, record_id: str
|
|
) -> Sequence[RecordDispositionRevision]:
|
|
query = session.query(RecordDispositionRevision).filter(
|
|
RecordDispositionRevision.tenant_id == tenant_id,
|
|
RecordDispositionRevision.record_id == record_id,
|
|
)
|
|
return (
|
|
apply_temporal_revision_filter(
|
|
query,
|
|
RecordDispositionRevision,
|
|
valid_from=None,
|
|
valid_to=None,
|
|
)
|
|
.order_by(RecordDispositionRevision.recorded_at.desc())
|
|
.all()
|
|
)
|
|
|
|
|
|
def _record_transfer_packages(
|
|
session: Session, *, tenant_id: str, record_id: str
|
|
) -> Sequence[RecordTransferPackageRevision]:
|
|
query = session.query(RecordTransferPackageRevision).filter(
|
|
RecordTransferPackageRevision.tenant_id == tenant_id,
|
|
RecordTransferPackageRevision.record_id == record_id,
|
|
)
|
|
return (
|
|
apply_temporal_revision_filter(
|
|
query,
|
|
RecordTransferPackageRevision,
|
|
valid_from=None,
|
|
valid_to=None,
|
|
)
|
|
.order_by(RecordTransferPackageRevision.recorded_at.desc())
|
|
.all()
|
|
)
|
|
|
|
|
|
def _active_holds(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
record_id: str,
|
|
at: datetime,
|
|
) -> Sequence[RecordHoldRevision]:
|
|
return (
|
|
session.query(RecordHoldRevision)
|
|
.filter(
|
|
RecordHoldRevision.tenant_id == tenant_id,
|
|
RecordHoldRevision.record_id == record_id,
|
|
RecordHoldRevision.superseded_at.is_(None),
|
|
RecordHoldRevision.status == "active",
|
|
RecordHoldRevision.effective_from <= at,
|
|
or_(
|
|
RecordHoldRevision.effective_to.is_(None),
|
|
RecordHoldRevision.effective_to > at,
|
|
),
|
|
)
|
|
.order_by(RecordHoldRevision.recorded_at)
|
|
.all()
|
|
)
|
|
|
|
|
|
def _require_no_active_hold(
|
|
session: Session, tenant_id: str, record_id: str, *, at: datetime
|
|
) -> None:
|
|
holds = _active_holds(session, tenant_id=tenant_id, record_id=record_id, at=at)
|
|
if holds:
|
|
raise RecordConflictError(
|
|
f"Disposition is blocked by {len(holds)} active record hold(s)."
|
|
)
|
|
|
|
|
|
def _append_event(
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
row: RecordRevision,
|
|
event_type: str,
|
|
summary: str,
|
|
occurred_at: datetime,
|
|
purpose: str,
|
|
idempotency_key: str,
|
|
request_hash: str,
|
|
institutional_context: Mapping[str, object],
|
|
payload: Mapping[str, object],
|
|
) -> RecordChronologyEntry:
|
|
event = RecordChronologyEntry(
|
|
tenant_id=row.tenant_id,
|
|
record_id=row.record_id,
|
|
event_id=str(uuid.uuid4()),
|
|
event_type=event_type,
|
|
record_revision=row.revision,
|
|
summary=summary[:500],
|
|
occurred_at=occurred_at,
|
|
actor_id=_actor(principal),
|
|
actor_assignment_id=_actor_assignment(principal),
|
|
actor_delegation_id=_actor_delegation(principal),
|
|
purpose=purpose,
|
|
idempotency_key=idempotency_key,
|
|
request_sha256=request_hash,
|
|
institutional_context=dict(institutional_context),
|
|
payload=dict(payload),
|
|
)
|
|
session.add(event)
|
|
return event
|
|
|
|
|
|
def _replay_event(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
idempotency_key: str,
|
|
request_hash: str,
|
|
) -> RecordChronologyEntry | None:
|
|
event = (
|
|
session.query(RecordChronologyEntry)
|
|
.filter(
|
|
RecordChronologyEntry.tenant_id == tenant_id,
|
|
RecordChronologyEntry.idempotency_key == idempotency_key,
|
|
)
|
|
.one_or_none()
|
|
)
|
|
if event is not None:
|
|
_verify_replay(event.request_sha256, request_hash)
|
|
return event
|
|
|
|
|
|
def _replay_by_key(
|
|
session: Session,
|
|
model: type,
|
|
*,
|
|
tenant_id: str,
|
|
idempotency_key: str,
|
|
request_hash: str,
|
|
):
|
|
row = (
|
|
session.query(model)
|
|
.filter(
|
|
model.tenant_id == tenant_id,
|
|
model.idempotency_key == idempotency_key,
|
|
)
|
|
.one_or_none()
|
|
)
|
|
if row is not None:
|
|
_verify_replay(row.request_sha256, request_hash)
|
|
return row
|
|
|
|
|
|
def _verify_replay(actual_hash: str, requested_hash: str) -> None:
|
|
if actual_hash != requested_hash:
|
|
raise RecordConflictError(
|
|
"The idempotency key was already used with a different request."
|
|
)
|
|
|
|
|
|
def _validate_expected(current: int | None, expected: object, *, label: str) -> None:
|
|
if current is None:
|
|
if expected is not None:
|
|
raise RecordConflictError(
|
|
f"{label} revision conflict: no current revision exists."
|
|
)
|
|
return
|
|
if expected is None or int(expected) != current:
|
|
raise RecordConflictError(
|
|
f"{label} revision conflict: expected {expected}, current revision is {current}."
|
|
)
|
|
|
|
|
|
def _validate_later_revision(previous: datetime | None, current: datetime) -> None:
|
|
if previous is not None and _aware(current) <= _aware(previous):
|
|
raise RecordConflictError(
|
|
"A new revision must be recorded after the current revision."
|
|
)
|
|
|
|
|
|
def _validate_interval(valid_from: datetime | None, valid_to: datetime | None) -> None:
|
|
if valid_from and valid_to and _aware(valid_to) <= _aware(valid_from):
|
|
raise RecordStoreError("valid_to must be after valid_from.")
|
|
|
|
|
|
def _revise_record_state(
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
current: RecordRevision,
|
|
recorded_at: datetime,
|
|
values: Mapping[str, object],
|
|
snapshot_patch: Mapping[str, object],
|
|
) -> tuple[RecordRevision, RecordIdentity]:
|
|
identity = session.get(RecordIdentity, current.identity_id)
|
|
if identity is None:
|
|
raise RecordStoreError("Record identity is missing.")
|
|
revision_values = _revision_values(current, {})
|
|
revision_values.update(values)
|
|
current.superseded_at = recorded_at
|
|
row = RecordRevision(
|
|
tenant_id=current.tenant_id,
|
|
record_id=current.record_id,
|
|
identity_id=current.identity_id,
|
|
revision=current.revision + 1,
|
|
previous_revision_id=current.id,
|
|
recorded_at=recorded_at,
|
|
changed_by=_actor(principal),
|
|
search_text=current.search_text,
|
|
snapshot={**dict(current.snapshot), **dict(snapshot_patch)},
|
|
**revision_values,
|
|
)
|
|
session.add(row)
|
|
return row, identity
|
|
|
|
|
|
def _calculate_retention(
|
|
record_class: RecordClassRevision,
|
|
*,
|
|
closed_at: datetime,
|
|
explicit_trigger_at: datetime | None,
|
|
) -> tuple[datetime | None, datetime | None, dict[str, Any]]:
|
|
period_days = record_class.retention_period_days
|
|
trigger_mode = (record_class.closure_trigger or "record_closed").strip().lower()
|
|
if period_days is None:
|
|
return (
|
|
None,
|
|
None,
|
|
{
|
|
"class_id": record_class.class_id,
|
|
"class_revision": record_class.revision,
|
|
"period_days": None,
|
|
"trigger": trigger_mode,
|
|
},
|
|
)
|
|
if trigger_mode in {"record_closed", "closed", "closure"}:
|
|
started_at = closed_at
|
|
elif trigger_mode in {"calendar_year_end", "year_end"}:
|
|
started_at = closed_at.replace(
|
|
year=closed_at.year + 1,
|
|
month=1,
|
|
day=1,
|
|
hour=0,
|
|
minute=0,
|
|
second=0,
|
|
microsecond=0,
|
|
)
|
|
elif trigger_mode in {"calendar_month_end", "month_end"}:
|
|
if closed_at.month == 12:
|
|
started_at = closed_at.replace(
|
|
year=closed_at.year + 1,
|
|
month=1,
|
|
day=1,
|
|
hour=0,
|
|
minute=0,
|
|
second=0,
|
|
microsecond=0,
|
|
)
|
|
else:
|
|
started_at = closed_at.replace(
|
|
month=closed_at.month + 1,
|
|
day=1,
|
|
hour=0,
|
|
minute=0,
|
|
second=0,
|
|
microsecond=0,
|
|
)
|
|
elif trigger_mode == "explicit":
|
|
if explicit_trigger_at is None:
|
|
raise RecordStoreError(
|
|
"This record class requires an explicit retention trigger date."
|
|
)
|
|
started_at = explicit_trigger_at
|
|
else:
|
|
raise RecordStoreError(
|
|
f"Unsupported record-class closure trigger: {trigger_mode}."
|
|
)
|
|
due_at = started_at + timedelta(days=period_days)
|
|
return (
|
|
started_at,
|
|
due_at,
|
|
{
|
|
"class_id": record_class.class_id,
|
|
"class_revision": record_class.revision,
|
|
"period_days": period_days,
|
|
"trigger": trigger_mode,
|
|
"started_at": _datetime_text(started_at),
|
|
"due_at": _datetime_text(due_at),
|
|
},
|
|
)
|
|
|
|
|
|
def _record_evidence_manifest(
|
|
session: Session, *, tenant_id: str, record: RecordRevision
|
|
) -> dict[str, Any]:
|
|
identity = session.get(RecordIdentity, record.identity_id)
|
|
if identity is None:
|
|
raise RecordStoreError("Record identity is missing.")
|
|
record_class = (
|
|
session.query(RecordClassRevision)
|
|
.filter(
|
|
RecordClassRevision.tenant_id == tenant_id,
|
|
RecordClassRevision.class_id == record.class_id,
|
|
RecordClassRevision.revision
|
|
== int(record.retention_rule.get("class_revision") or 0),
|
|
)
|
|
.one_or_none()
|
|
)
|
|
if record_class is None:
|
|
record_class = _current_class(session, tenant_id, record.class_id)
|
|
items = (
|
|
session.query(RecordItem)
|
|
.filter(
|
|
RecordItem.tenant_id == tenant_id,
|
|
RecordItem.record_id == record.record_id,
|
|
)
|
|
.order_by(RecordItem.sequence)
|
|
.all()
|
|
)
|
|
active_holds = _active_holds(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
record_id=record.record_id,
|
|
at=datetime.now(UTC),
|
|
)
|
|
return {
|
|
"record_id": record.record_id,
|
|
"record_number": identity.record_number,
|
|
"record_revision": record.revision,
|
|
"recorded_at": _datetime_text(record.recorded_at),
|
|
"state": record.state,
|
|
"class": (
|
|
{
|
|
"class_id": record_class.class_id,
|
|
"revision": record_class.revision,
|
|
"key": record_class.key,
|
|
"retention_period_days": record_class.retention_period_days,
|
|
"closure_trigger": record_class.closure_trigger,
|
|
}
|
|
if record_class is not None
|
|
else {"class_id": record.class_id, "revision": None}
|
|
),
|
|
"retention": {
|
|
"closed_at": _datetime_text(record.closed_at),
|
|
"started_at": _datetime_text(record.retention_started_at),
|
|
"due_at": _datetime_text(record.retention_due_at),
|
|
"rule": dict(record.retention_rule),
|
|
},
|
|
"appraisal": dict(record.appraisal),
|
|
"items": [
|
|
{
|
|
"item_id": item.id,
|
|
"sequence": item.sequence,
|
|
"source_module": item.source_module,
|
|
"resource_type": item.resource_type,
|
|
"resource_id": item.resource_id,
|
|
"source_revision": item.source_revision,
|
|
"authority_mode": item.authority_mode,
|
|
"content_sha256": item.content_sha256,
|
|
"filed_at": _datetime_text(item.filed_at),
|
|
}
|
|
for item in items
|
|
],
|
|
"active_holds": [
|
|
{
|
|
"hold_id": hold.hold_id,
|
|
"revision": hold.revision,
|
|
"authority": hold.authority,
|
|
"effective_from": _datetime_text(hold.effective_from),
|
|
"effective_to": _datetime_text(hold.effective_to),
|
|
}
|
|
for hold in active_holds
|
|
],
|
|
"institutional_context": dict(record.institutional_context),
|
|
}
|
|
|
|
|
|
def _disposition_consequence_preview(
|
|
*,
|
|
action: str,
|
|
record: RecordRevision,
|
|
evidence: Mapping[str, object],
|
|
approvals_available: bool,
|
|
) -> dict[str, Any]:
|
|
descriptions = {
|
|
"retain": "Retain the record under its current classification without an external effect.",
|
|
"transfer": "Prepare an archive-neutral package; dispatch remains a separate governed action.",
|
|
"destroy": "Mark destruction as pending only; no content or source object is deleted by approval.",
|
|
"reclassify": "Return the record to an open state so a governed classification revision can follow.",
|
|
}
|
|
return {
|
|
"action": action,
|
|
"description": descriptions[action],
|
|
"record_revision": record.revision,
|
|
"item_count": len(list(evidence.get("items") or [])),
|
|
"destructive_effect": False,
|
|
"external_effect": False,
|
|
"requires_independent_approval": True,
|
|
"approval_capability_available": approvals_available,
|
|
"limitations": [
|
|
"Approval changes lifecycle state only; destructive and external effects require separate evidence-bound operations."
|
|
],
|
|
}
|
|
|
|
|
|
def _revision_values(
|
|
current: RecordRevision, payload: Mapping[str, object]
|
|
) -> dict[str, Any]:
|
|
def selected(name: str) -> object:
|
|
return payload[name] if name in payload else getattr(current, name)
|
|
|
|
return {
|
|
"class_id": str(selected("class_id")),
|
|
"file_plan_node_id": str(selected("file_plan_node_id")),
|
|
"title": str(selected("title")),
|
|
"description": _optional_text(selected("description")),
|
|
"state": str(selected("state")),
|
|
"source_authority_mode": current.source_authority_mode,
|
|
"access_mode": str(selected("access_mode")),
|
|
"purpose": current.purpose,
|
|
"classification": _optional_text(selected("classification")),
|
|
"responsible_unit_id": _optional_text(selected("responsible_unit_id")),
|
|
"responsible_function_id": _optional_text(selected("responsible_function_id")),
|
|
"external_reference": dict(current.external_reference),
|
|
"institutional_context": _mapping(selected("institutional_context")),
|
|
"valid_from": (
|
|
_optional_timestamp(payload.get("valid_from"), "valid_from")
|
|
if "valid_from" in payload
|
|
else current.valid_from
|
|
),
|
|
"valid_to": (
|
|
_optional_timestamp(payload.get("valid_to"), "valid_to")
|
|
if "valid_to" in payload
|
|
else current.valid_to
|
|
),
|
|
"closed_at": current.closed_at,
|
|
"retention_started_at": current.retention_started_at,
|
|
"retention_due_at": current.retention_due_at,
|
|
"retention_rule": dict(current.retention_rule),
|
|
"appraisal_state": current.appraisal_state,
|
|
"appraisal": dict(current.appraisal),
|
|
}
|
|
|
|
|
|
def _record_snapshot(payload: Mapping[str, object]) -> dict[str, Any]:
|
|
return _json_mapping(payload)
|
|
|
|
|
|
def _record_search_text(payload: Mapping[str, object], record_number: str) -> str:
|
|
return " ".join(
|
|
value
|
|
for value in (
|
|
record_number,
|
|
str(payload.get("title") or ""),
|
|
str(payload.get("description") or ""),
|
|
str(payload.get("classification") or ""),
|
|
)
|
|
if value
|
|
).lower()
|
|
|
|
|
|
def _file_plan_dict(row: RecordFilePlanRevision) -> dict[str, Any]:
|
|
return {
|
|
"node_id": row.node_id,
|
|
"revision": row.revision,
|
|
"parent_node_id": row.parent_node_id,
|
|
"code": row.code,
|
|
"label": row.label,
|
|
"description": row.description,
|
|
"active": row.active,
|
|
"valid_from": _datetime_text(row.valid_from),
|
|
"valid_to": _datetime_text(row.valid_to),
|
|
"recorded_at": _datetime_text(row.recorded_at),
|
|
"institutional_context": dict(row.institutional_context),
|
|
}
|
|
|
|
|
|
def _class_dict(row: RecordClassRevision) -> dict[str, Any]:
|
|
return {
|
|
"class_id": row.class_id,
|
|
"revision": row.revision,
|
|
"file_plan_node_id": row.file_plan_node_id,
|
|
"key": row.key,
|
|
"label": row.label,
|
|
"description": row.description,
|
|
"metadata_requirements": list(row.metadata_requirements),
|
|
"allowed_source_types": list(row.allowed_source_types),
|
|
"retention_period_days": row.retention_period_days,
|
|
"closure_trigger": row.closure_trigger,
|
|
"access_mode": row.access_mode,
|
|
"active": row.active,
|
|
"valid_from": _datetime_text(row.valid_from),
|
|
"valid_to": _datetime_text(row.valid_to),
|
|
"recorded_at": _datetime_text(row.recorded_at),
|
|
"institutional_context": dict(row.institutional_context),
|
|
}
|
|
|
|
|
|
def _record_dict(row: RecordRevision, identity: RecordIdentity) -> dict[str, Any]:
|
|
return {
|
|
"reference": {
|
|
"kind": "record",
|
|
"owner_module": "records",
|
|
"object_id": row.record_id,
|
|
"tenant_id": row.tenant_id,
|
|
"version": str(row.revision),
|
|
},
|
|
"record_id": row.record_id,
|
|
"record_number": identity.record_number,
|
|
"revision": row.revision,
|
|
"class_id": row.class_id,
|
|
"file_plan_node_id": row.file_plan_node_id,
|
|
"title": row.title,
|
|
"description": row.description,
|
|
"state": row.state,
|
|
"source_authority_mode": row.source_authority_mode,
|
|
"access_mode": row.access_mode,
|
|
"purpose": row.purpose,
|
|
"classification": row.classification,
|
|
"responsible_unit_id": row.responsible_unit_id,
|
|
"responsible_function_id": row.responsible_function_id,
|
|
"external_reference": dict(row.external_reference),
|
|
"institutional_context": dict(row.institutional_context),
|
|
"valid_from": _datetime_text(row.valid_from),
|
|
"valid_to": _datetime_text(row.valid_to),
|
|
"recorded_at": _datetime_text(row.recorded_at),
|
|
"closed_at": _datetime_text(row.closed_at),
|
|
"retention_started_at": _datetime_text(row.retention_started_at),
|
|
"retention_due_at": _datetime_text(row.retention_due_at),
|
|
"retention_rule": dict(row.retention_rule),
|
|
"appraisal_state": row.appraisal_state,
|
|
"appraisal": dict(row.appraisal),
|
|
}
|
|
|
|
|
|
def _volume_dict(row: RecordVolumeRevision) -> dict[str, Any]:
|
|
return {
|
|
"volume_id": row.volume_id,
|
|
"record_id": row.record_id,
|
|
"revision": row.revision,
|
|
"sequence": row.sequence,
|
|
"label": row.label,
|
|
"state": row.state,
|
|
"valid_from": _datetime_text(row.valid_from),
|
|
"valid_to": _datetime_text(row.valid_to),
|
|
"recorded_at": _datetime_text(row.recorded_at),
|
|
}
|
|
|
|
|
|
def _item_dict(row: RecordItem) -> dict[str, Any]:
|
|
return {
|
|
"item_id": row.id,
|
|
"record_id": row.record_id,
|
|
"volume_id": row.volume_id,
|
|
"sequence": row.sequence,
|
|
"source": {
|
|
"source_module": row.source_module,
|
|
"resource_type": row.resource_type,
|
|
"resource_id": row.resource_id,
|
|
"source_revision": row.source_revision,
|
|
},
|
|
"label": row.label,
|
|
"relationship": row.relationship,
|
|
"filing_reason": row.filing_reason,
|
|
"purpose": row.purpose,
|
|
"authority_mode": row.authority_mode,
|
|
"content_sha256": row.content_sha256,
|
|
"content_type": row.content_type,
|
|
"size_bytes": row.size_bytes,
|
|
"source_valid_from": _datetime_text(row.source_valid_from),
|
|
"source_valid_to": _datetime_text(row.source_valid_to),
|
|
"source_recorded_at": _datetime_text(row.source_recorded_at),
|
|
"launch_url": row.launch_url,
|
|
"filed_at": _datetime_text(row.filed_at),
|
|
"filed_by": row.filed_by,
|
|
"institutional_context": dict(row.institutional_context),
|
|
"source_metadata": dict(row.source_metadata),
|
|
"filing_metadata": dict(row.filing_metadata),
|
|
}
|
|
|
|
|
|
def _chronology_dict(row: RecordChronologyEntry) -> dict[str, Any]:
|
|
return {
|
|
"event_id": row.event_id,
|
|
"event_type": row.event_type,
|
|
"record_revision": row.record_revision,
|
|
"summary": row.summary,
|
|
"occurred_at": _datetime_text(row.occurred_at),
|
|
"actor_id": row.actor_id,
|
|
"purpose": row.purpose,
|
|
"institutional_context": dict(row.institutional_context),
|
|
"payload": dict(row.payload),
|
|
}
|
|
|
|
|
|
def _hold_dict(row: RecordHoldRevision) -> dict[str, Any]:
|
|
return {
|
|
"hold_id": row.hold_id,
|
|
"record_id": row.record_id,
|
|
"revision": row.revision,
|
|
"status": row.status,
|
|
"reason": row.reason,
|
|
"authority": row.authority,
|
|
"scope": dict(row.scope),
|
|
"effective_from": _datetime_text(row.effective_from),
|
|
"effective_to": _datetime_text(row.effective_to),
|
|
"released_at": _datetime_text(row.released_at),
|
|
"policy_refs": list(row.policy_refs),
|
|
"institutional_context": dict(row.institutional_context),
|
|
"recorded_at": _datetime_text(row.recorded_at),
|
|
"changed_by": row.changed_by,
|
|
}
|
|
|
|
|
|
def _disposition_dict(row: RecordDispositionRevision) -> dict[str, Any]:
|
|
return {
|
|
"disposition_id": row.disposition_id,
|
|
"record_id": row.record_id,
|
|
"revision": row.revision,
|
|
"action": row.action,
|
|
"status": row.status,
|
|
"reason": row.reason,
|
|
"subject_revision": row.subject_revision,
|
|
"subject_sha256": row.subject_sha256,
|
|
"consequence_preview": dict(row.consequence_preview),
|
|
"policy_refs": list(row.policy_refs),
|
|
"approval_request_id": row.approval_request_id,
|
|
"proposed_by": row.proposed_by,
|
|
"reviewed_by": row.reviewed_by,
|
|
"reviewed_at": _datetime_text(row.reviewed_at),
|
|
"institutional_context": dict(row.institutional_context),
|
|
"recorded_at": _datetime_text(row.recorded_at),
|
|
}
|
|
|
|
|
|
def _transfer_package_dict(row: RecordTransferPackageRevision) -> dict[str, Any]:
|
|
return {
|
|
"package_id": row.package_id,
|
|
"record_id": row.record_id,
|
|
"disposition_id": row.disposition_id,
|
|
"revision": row.revision,
|
|
"record_revision": row.record_revision,
|
|
"provider_id": row.provider_id,
|
|
"profile": row.profile,
|
|
"status": row.status,
|
|
"authority_mode": row.authority_mode,
|
|
"manifest": dict(row.manifest),
|
|
"manifest_sha256": row.manifest_sha256,
|
|
"receipt": dict(row.receipt),
|
|
"receipt_sha256": row.receipt_sha256,
|
|
"external_reference": row.external_reference,
|
|
"recovery_operation_id": row.recovery_operation_id,
|
|
"simulated": row.simulated,
|
|
"institutional_context": dict(row.institutional_context),
|
|
"recorded_at": _datetime_text(row.recorded_at),
|
|
"changed_by": row.changed_by,
|
|
}
|
|
|
|
|
|
def _filing_request_mapping(request: RecordFilingRequest) -> dict[str, object]:
|
|
return {
|
|
"tenant_id": request.tenant_id,
|
|
"record_id": request.record_id,
|
|
"source": {
|
|
"tenant_id": request.source.tenant_id,
|
|
"source_module": request.source.source_module,
|
|
"resource_type": request.source.resource_type,
|
|
"resource_id": request.source.resource_id,
|
|
"source_revision": request.source.source_revision,
|
|
"metadata": dict(request.source.metadata),
|
|
},
|
|
"purpose": request.purpose,
|
|
"filing_reason": request.filing_reason,
|
|
"idempotency_key": request.idempotency_key,
|
|
"volume_id": request.volume_id,
|
|
"relationship": request.relationship,
|
|
"institutional_context": dict(request.institutional_context),
|
|
"metadata": dict(request.metadata),
|
|
}
|
|
|
|
|
|
def _filing_result(row: RecordItem, *, replayed: bool) -> RecordFilingResult:
|
|
locator = RecordSourceLocator(
|
|
tenant_id=row.tenant_id,
|
|
source_module=row.source_module,
|
|
resource_type=row.resource_type,
|
|
resource_id=row.resource_id,
|
|
source_revision=row.source_revision,
|
|
)
|
|
reference = RecordSourceReference(
|
|
locator=locator,
|
|
label=row.label,
|
|
authority_mode=row.authority_mode, # type: ignore[arg-type]
|
|
content_sha256=row.content_sha256,
|
|
content_type=row.content_type,
|
|
size_bytes=row.size_bytes,
|
|
valid_from=row.source_valid_from,
|
|
valid_to=row.source_valid_to,
|
|
recorded_at=row.source_recorded_at,
|
|
launch_url=row.launch_url,
|
|
metadata=dict(row.source_metadata),
|
|
)
|
|
return RecordFilingResult(
|
|
record_id=row.record_id,
|
|
item_id=row.id,
|
|
sequence=row.sequence,
|
|
source=reference,
|
|
filed_at=_aware(row.filed_at),
|
|
replayed=replayed,
|
|
)
|
|
|
|
|
|
def _tenant(principal: object) -> str:
|
|
value = _principal_value(principal, "tenant_id")
|
|
if not value:
|
|
raise RecordStoreError("Records operations require a tenant-bound principal.")
|
|
return value
|
|
|
|
|
|
def _actor(principal: object) -> str | None:
|
|
for name in ("account_id", "identity_id", "membership_id"):
|
|
value = _principal_value(principal, name)
|
|
if value:
|
|
return value
|
|
return None
|
|
|
|
|
|
def _actor_assignment(principal: object) -> str | None:
|
|
return _principal_value(principal, "acting_assignment_id") or _principal_value(
|
|
principal, "assignment_id"
|
|
)
|
|
|
|
|
|
def _actor_delegation(principal: object) -> str | None:
|
|
return _principal_value(principal, "acting_delegation_id") or _principal_value(
|
|
principal, "delegation_id"
|
|
)
|
|
|
|
|
|
def _principal_value(principal: object, name: str) -> str | None:
|
|
value = str(getattr(principal, name, "") or "").strip()
|
|
return value or None
|
|
|
|
|
|
def _text(
|
|
payload: Mapping[str, object], name: str, *, default: str | None = None
|
|
) -> str:
|
|
value = str(payload.get(name, default) or "").strip()
|
|
if not value:
|
|
raise RecordStoreError(f"{name} is required.")
|
|
return value
|
|
|
|
|
|
def _optional_text(value: object) -> str | None:
|
|
text = str(value or "").strip()
|
|
return text or None
|
|
|
|
|
|
def _text_list(value: object) -> list[str]:
|
|
if value is None:
|
|
return []
|
|
if not isinstance(value, (list, tuple)):
|
|
raise RecordStoreError("Expected a list of text values.")
|
|
return list(dict.fromkeys(str(item).strip() for item in value if str(item).strip()))
|
|
|
|
|
|
def _mapping(value: object) -> dict[str, Any]:
|
|
if value is None:
|
|
return {}
|
|
if not isinstance(value, Mapping):
|
|
raise RecordStoreError("Expected an object value.")
|
|
return _json_mapping(value)
|
|
|
|
|
|
def _json_mapping(value: Mapping[str, object]) -> dict[str, Any]:
|
|
return json.loads(json.dumps(dict(value), default=_json_default))
|
|
|
|
|
|
def _json_default(value: object) -> str:
|
|
if isinstance(value, datetime):
|
|
return _datetime_text(value) or ""
|
|
return str(value)
|
|
|
|
|
|
def _request_hash(payload: Mapping[str, object]) -> str:
|
|
encoded = json.dumps(
|
|
dict(payload), sort_keys=True, separators=(",", ":"), default=_json_default
|
|
)
|
|
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _timestamp(value: object, name: str) -> datetime:
|
|
parsed = _optional_timestamp(value, name)
|
|
if parsed is None:
|
|
raise RecordStoreError(f"{name} is required.")
|
|
return parsed
|
|
|
|
|
|
def _optional_timestamp(value: object, name: str) -> datetime | None:
|
|
if value is None or value == "":
|
|
return None
|
|
if isinstance(value, datetime):
|
|
parsed = value
|
|
else:
|
|
try:
|
|
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
except ValueError as exc:
|
|
raise RecordStoreError(f"{name} must be an ISO 8601 timestamp.") from exc
|
|
if parsed.tzinfo is None:
|
|
raise RecordStoreError(f"{name} must include a timezone.")
|
|
return parsed.astimezone(UTC)
|
|
|
|
|
|
def _optional_int(value: object) -> int | None:
|
|
return None if value is None or value == "" else int(value)
|
|
|
|
|
|
def _aware(value: datetime) -> datetime:
|
|
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
|
|
|
|
|
|
def _datetime_text(value: datetime | None) -> str | None:
|
|
return (
|
|
_aware(value).isoformat().replace("+00:00", "Z") if value is not None else None
|
|
)
|
|
|
|
|
|
__all__ = [
|
|
"RecordConflictError",
|
|
"RecordNotFoundError",
|
|
"RecordSourceUnavailableError",
|
|
"RecordStoreError",
|
|
"SqlRecordRegistry",
|
|
]
|