feat(records): govern lifecycle recovery and transfer evidence

This commit is contained in:
2026-08-06 05:36:18 +02:00
parent f316523a1d
commit df51bb1787
22 changed files with 4954 additions and 66 deletions
+38 -13
View File
@@ -17,8 +17,8 @@ Implementation is tracked in
## Implementation Status
The native foundation is implemented through the work packages tracked by
Records #2-#4:
The native foundation and governed lifecycle are implemented through the work
packages tracked by Records #2-#6:
- versioned file plans and record classes;
- stable records, immutable revisions, volumes, exact record items, and
@@ -29,13 +29,24 @@ Records #2-#4:
temporal status, create/edit, and filing actions;
- a provider-neutral Core filing contract with exact Files-version and
Cases-revision providers.
- governed close/reopen transitions, class-bound retention calculation,
effective-dated holds, appraisal, evidence-bound disposition proposals, and
mandatory independent approval before finalization;
- archive-neutral transfer manifests and bounded receipts, plus an explicitly
non-conformant simulation provider that never claims custody;
- durable recovery-ledger fences for every API write, atomic domain/checkpoint
commits, Audit events, source-revision revalidation, transfer-manifest
checksum checks, and an operator recovery-evidence view;
- tenant administration for versioned file-plan nodes and record classes,
explicit volume management, and lifecycle controls in the eAkte workspace.
The remaining delivery order is intentionally visible rather than implied:
Records #5 owns closure, retention, holds, appraisal, and disposition; #6 owns
recovery and signed evidence; #7 requires selection and target testing of an
archive/xdomea provider; and #8 proves the reference journey. Restricted
per-record access grants also remain a dedicated access-policy slice. No
archive or destructive effect is currently claimed.
The remaining boundary is intentionally visible rather than implied: Records
#7 requires selection and target testing of an archive/xdomea endpoint and
conformance profile; #8 completes the cross-module reference journey and
production evidence. Restricted per-record access grants also remain a
dedicated access-policy slice. Destruction is represented only as an approved
pending state; no content deletion or real archive effect is currently
claimed.
## Ownership Boundary
@@ -103,10 +114,15 @@ planned -> open -> closed -> retention_running -> appraisal_due
destroyed
```
Reopening creates a governed transition and does not reset elapsed retention
without an explicit rule. A hold preserves the reason and affected scope. A
destruction action requires exact manifest, current authority, policy,
approval, preflight, idempotency, outcome-unknown recovery, and evidence.
Reopening creates a governed transition and preserves the preceding retention
schedule. A later closure only restarts retention under an explicit
administrator action. A hold preserves reason, authority, scope, effective
interval, policy references, and release evidence. Holds block proposal,
approval finalization, packaging, and dispatch. A destruction approval changes
the record to `destruction_pending`; no source object or content is deleted.
An unapproved disposition can be withdrawn through a new immutable revision so
a corrected proposal can supersede it. An approved disposition cannot use this
correction path.
## Filing Semantics
@@ -189,10 +205,17 @@ available in an evidence/details view.
storage or an external provider, never node-local paths.
- Every external filing, transfer, or destruction uses intent-before-effect,
idempotency, durable receipts, outcome-unknown state, and reconciliation.
- Native Records API writes use a per-resource distributed lease and the Core
recovery ledger. Immutable revision, chronology, Audit projection, and the
terminal checkpoint commit atomically.
- Backup evidence binds record rows, object manifests, provider mappings,
policy/configuration versions, and key references.
- Restore verifies content digests, missing keys/objects, provider reachability,
and disposition holds before reopening effects.
- Automated evidence tests prove that terminal Records operations and their
hash chain remain verifiable after a database backup/restore round trip. An
outcome-unknown transfer is persisted as non-retryable and cannot advance
custody or record disposition state.
- Search indexes are rebuildable projections and cannot become record
authority.
@@ -203,8 +226,10 @@ available in an evidence/details view.
2. Integrate filing from Cases, Forms Runtime, Decisions, Campaign/Postbox,
Files, and Reporting.
3. Add closure, retention calculation, holds, appraisal, and reviewed
disposition without destructive provider effects.
disposition without destructive provider effects. **Implemented.**
4. Add native transfer packages and one target-tested xdomea/archive provider.
Native packaging and simulation are implemented; target selection/testing
remains external.
5. Add destruction/recovery, TR-ESOR provider integration, migration, and
signed reference-journey evidence.
+14 -6
View File
@@ -42,16 +42,24 @@ The native kernel currently provides:
file versions and Cases revisions;
- tenant APIs, search projection, uninstall/retirement guards, and a Records
workspace using shared WebUI controls.
- immutable close/reopen, retention, hold, appraisal, and independently
approved disposition transitions;
- archive-neutral manifests, bounded receipts, and a clearly marked transfer
simulation that does not claim custody;
- Core recovery-ledger adoption, atomic terminal evidence, Audit projection,
source and package restore diagnostics, catalog administration, volumes, and
lifecycle UI.
Restricted object grants and the lifecycle after the planned/open stages remain
separate governed slices. Archive transfer and destructive effects are not
implemented by this kernel.
Restricted object grants remain a separate governed slice. A target-tested
archive adapter and any destructive effect remain deliberately unimplemented;
approved destruction is only a pending lifecycle state.
## First Implementation Slice
Complete restricted access, closure and retention calculation, holds,
appraisal, disposition, transfer, recovery evidence, and one target-tested
archive provider without moving source-module ownership into Records.
Complete restricted access and one target-tested archive provider without
moving source-module ownership into Records. Extend the executable reference
journey across assisted service, decision, filing, hold, restore, search, and
archive simulation before claiming production maturity.
The complete native/external boundary, temporal and purpose-aware record model,
disposition lifecycle, German public-sector provider profiles, and staged
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
from datetime import UTC, datetime
import hashlib
import json
from govoplan_core.core.records import (
RecordArchiveProviderState,
RecordArchiveReceipt,
RecordArchiveTransferRequest,
RecordContractError,
)
SIMULATION_PROVIDER_ID = "simulation"
SIMULATION_PROFILE = "govoplan-simulation-v1"
class SimulatedRecordArchiveProvider:
"""Exercise the transfer boundary without claiming archival custody."""
provider_id = SIMULATION_PROVIDER_ID
def state(self) -> RecordArchiveProviderState:
checked_at = datetime.now(UTC)
return RecordArchiveProviderState(
provider_id=self.provider_id,
label="Records transfer simulation",
profiles=(SIMULATION_PROFILE,),
authority_modes=("linked_reference",),
healthy=True,
checked_at=checked_at,
last_success_at=checked_at,
freshness_seconds=0,
limitations=(
"Simulation validates package and receipt handling but does not transfer custody.",
"It is not an xDOMEA or archival conformance profile.",
),
simulated=True,
)
def dispatch(
self,
session: object,
principal: object,
*,
request: RecordArchiveTransferRequest,
) -> RecordArchiveReceipt:
del session, principal
if request.package.profile != SIMULATION_PROFILE:
raise RecordContractError(
"The simulation provider only accepts its declared profile."
)
observed_at = datetime.now(UTC)
receipt_payload = {
"provider_id": self.provider_id,
"package_id": request.package.package_id,
"manifest_sha256": request.package.manifest_sha256,
"profile": request.package.profile,
"outcome": "accepted",
"simulated": True,
}
receipt_sha256 = hashlib.sha256(
json.dumps(
receipt_payload,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
).hexdigest()
return RecordArchiveReceipt(
provider_id=self.provider_id,
package_id=request.package.package_id,
outcome="accepted",
observed_at=observed_at,
receipt_sha256=receipt_sha256,
external_reference=f"simulation:{request.package.package_id}",
retry_safe=True,
simulated=True,
metadata={
"manifest_sha256": request.package.manifest_sha256,
"profile": request.package.profile,
"custody_transferred": False,
},
)
__all__ = [
"SIMULATION_PROFILE",
"SIMULATION_PROVIDER_ID",
"SimulatedRecordArchiveProvider",
]
@@ -1,19 +1,25 @@
from govoplan_records.backend.db.models import (
RecordChronologyEntry,
RecordClassRevision,
RecordDispositionRevision,
RecordFilePlanRevision,
RecordHoldRevision,
RecordIdentity,
RecordItem,
RecordRevision,
RecordTransferPackageRevision,
RecordVolumeRevision,
)
__all__ = [
"RecordChronologyEntry",
"RecordClassRevision",
"RecordDispositionRevision",
"RecordFilePlanRevision",
"RecordHoldRevision",
"RecordIdentity",
"RecordItem",
"RecordRevision",
"RecordTransferPackageRevision",
"RecordVolumeRevision",
]
+203
View File
@@ -240,6 +240,24 @@ class RecordRevision(Base, TimestampMixin):
changed_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
closed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
retention_started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
retention_due_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
retention_rule: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
appraisal_state: Mapped[str | None] = mapped_column(
String(40), nullable=True, index=True
)
appraisal: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
@@ -387,12 +405,197 @@ class RecordChronologyEntry(Base, TimestampMixin):
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class RecordHoldRevision(Base, TimestampMixin):
__tablename__ = "record_hold_revisions"
__table_args__ = (
UniqueConstraint(
"tenant_id", "hold_id", "revision", name="uq_record_hold_revision"
),
UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_record_hold_idempotency"
),
Index("ix_record_hold_current", "tenant_id", "hold_id", "superseded_at"),
Index("ix_record_hold_record", "tenant_id", "record_id", "status"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
hold_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
record_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
previous_revision_id: Mapped[str | None] = mapped_column(
ForeignKey("record_hold_revisions.id", ondelete="RESTRICT"),
nullable=True,
index=True,
)
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
reason: Mapped[str] = mapped_column(Text, nullable=False)
authority: Mapped[str] = mapped_column(String(500), nullable=False)
scope: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
effective_from: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
effective_to: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
released_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
policy_refs: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
institutional_context: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
superseded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
changed_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
class RecordDispositionRevision(Base, TimestampMixin):
__tablename__ = "record_disposition_revisions"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"disposition_id",
"revision",
name="uq_record_disposition_revision",
),
UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_record_disposition_idempotency"
),
Index(
"ix_record_disposition_current",
"tenant_id",
"disposition_id",
"superseded_at",
),
Index("ix_record_disposition_record", "tenant_id", "record_id", "status"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
disposition_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
record_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
previous_revision_id: Mapped[str | None] = mapped_column(
ForeignKey("record_disposition_revisions.id", ondelete="RESTRICT"),
nullable=True,
index=True,
)
action: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
status: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
reason: Mapped[str] = mapped_column(Text, nullable=False)
subject_revision: Mapped[int] = mapped_column(Integer, nullable=False)
subject_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
consequence_preview: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
policy_refs: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
approval_request_id: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
proposed_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
reviewed_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
reviewed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
institutional_context: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
superseded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
class RecordTransferPackageRevision(Base, TimestampMixin):
__tablename__ = "record_transfer_package_revisions"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"package_id",
"revision",
name="uq_record_transfer_package_revision",
),
UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_record_transfer_package_idempotency",
),
Index(
"ix_record_transfer_package_current",
"tenant_id",
"package_id",
"superseded_at",
),
Index("ix_record_transfer_package_record", "tenant_id", "record_id", "status"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
package_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
record_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
disposition_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
previous_revision_id: Mapped[str | None] = mapped_column(
ForeignKey("record_transfer_package_revisions.id", ondelete="RESTRICT"),
nullable=True,
index=True,
)
record_revision: Mapped[int] = mapped_column(Integer, nullable=False)
provider_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
profile: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
authority_mode: Mapped[str] = mapped_column(String(40), nullable=False)
manifest: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
manifest_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
receipt: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
receipt_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
external_reference: Mapped[str | None] = mapped_column(String(1500), nullable=True)
recovery_operation_id: Mapped[str | None] = mapped_column(
String(36), nullable=True, index=True
)
simulated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
institutional_context: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
superseded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
changed_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
__all__ = [
"RecordChronologyEntry",
"RecordClassRevision",
"RecordFilePlanRevision",
"RecordHoldRevision",
"RecordIdentity",
"RecordItem",
"RecordDispositionRevision",
"RecordRevision",
"RecordTransferPackageRevision",
"RecordVolumeRevision",
]
+122 -23
View File
@@ -29,13 +29,20 @@ from govoplan_core.core.modules import (
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.records import CAPABILITY_RECORDS_FILING
from govoplan_core.core.records import (
CAPABILITY_RECORDS_FILING,
record_archive_capability,
)
from govoplan_core.core.search import SearchSourceProviderRegistration
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_records.backend.db import models as record_models
from govoplan_records.backend.search_source import create_records_search_source
from govoplan_records.backend.service import SqlRecordRegistry
from govoplan_records.backend.archive import (
SIMULATION_PROVIDER_ID,
SimulatedRecordArchiveProvider,
)
MODULE_ID = "records"
@@ -55,6 +62,7 @@ OPTIONAL_DEPENDENCIES = (
"dms",
"docs",
"policy",
"approvals",
"audit",
"transparency",
"search",
@@ -85,6 +93,13 @@ def _records_registry(context: ModuleContext) -> SqlRecordRegistry:
return SqlRecordRegistry(context.registry)
def _simulated_archive_provider(
context: ModuleContext,
) -> SimulatedRecordArchiveProvider:
del context
return SimulatedRecordArchiveProvider()
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
records = (
session.query(record_models.RecordIdentity)
@@ -100,7 +115,32 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
)
.count()
)
return {"records": records, "open_records": open_records}
active_holds = (
session.query(record_models.RecordHoldRevision)
.filter(
record_models.RecordHoldRevision.tenant_id == tenant_id,
record_models.RecordHoldRevision.superseded_at.is_(None),
record_models.RecordHoldRevision.status == "active",
)
.count()
)
pending_dispositions = (
session.query(record_models.RecordDispositionRevision)
.filter(
record_models.RecordDispositionRevision.tenant_id == tenant_id,
record_models.RecordDispositionRevision.superseded_at.is_(None),
record_models.RecordDispositionRevision.status.in_(
("review_pending", "review_unavailable")
),
)
.count()
)
return {
"records": records,
"open_records": open_records,
"active_holds": active_holds,
"pending_dispositions": pending_dispositions,
}
PERMISSIONS = (
@@ -247,14 +287,16 @@ DOCUMENTATION = (
},
),
DocumentationTopic(
id="records.lifecycle-limitations",
title="Records lifecycle limitations",
summary="Identifies lifecycle controls intentionally deferred beyond the native kernel.",
id="records.lifecycle",
title="Governed records lifecycle",
summary="Close, retain, hold, appraise, approve, and package records with durable evidence.",
body=(
"The current vertical supports planned and open records. Restricted object grants, closure, "
"retention calculation, holds, appraisal, disposition, transfer, destruction, and external "
"archive effects are separate governed work packages. No destructive effect is implied by "
"enabling Records."
"Closing a record applies the versioned record-class retention rule. Reopening preserves the "
"previous schedule unless an administrator deliberately restarts it. Effective-dated holds block "
"disposition and transfer. Appraisal selects retain, transfer, destroy, or reclassify; a disposition "
"binds the exact evidence digest and requires independent approval through Approvals. Approval only "
"changes lifecycle state. Destruction remains pending and transfer simulation never claims custody. "
"Every API mutation is fenced and recorded in the Core recovery ledger."
),
layer="configured",
documentation_types=("admin", "user"),
@@ -263,17 +305,44 @@ DOCUMENTATION = (
related_modules=("policy", "approvals", "audit", "dms"),
translations={
"de": {
"title": "Grenzen des Aktenlebenszyklus",
"summary": "Kennzeichnet bewusst nach dem nativen Kern umzusetzende Lebenszyklussteuerungen.",
"title": "Gesteuerter Aktenlebenszyklus",
"summary": "Akten mit dauerhaftem Nachweis abschließen, aufbewahren, sperren, bewerten, freigeben und paketieren.",
"body": (
"Der aktuelle Stand unterstützt geplante und offene Akten. Objektbezogene Freigaben, "
"Abschluss, Aufbewahrungsberechnung, Sperren, Bewertung, Aussonderung, Übergabe, Vernichtung "
"und externe Archiveffekte sind getrennte gesteuerte Arbeitspakete. Die Aktivierung von "
"Records löst keine vernichtende Wirkung aus."
"Beim Abschluss einer Akte wird die versionierte Aufbewahrungsregel der Aktenklasse angewendet. "
"Eine Wiedereröffnung bewahrt den bisherigen Zeitplan, sofern ein Administrator ihn nicht "
"bewusst neu startet. Gültigkeitsbezogene Sperren blockieren Aussonderung und Übergabe. Die "
"Bewertung wählt Aufbewahrung, Übergabe, Vernichtung oder Neuklassifikation; die Aussonderung "
"bindet den exakten Nachweis und benötigt eine unabhängige Freigabe durch Approvals. Eine "
"Freigabe ändert nur den Lebenszyklusstatus. Vernichtung bleibt vorgemerkt, und eine Simulation "
"behauptet keine Archivverwahrung. Jede API-Änderung wird im Recovery-Ledger abgesichert."
),
}
},
metadata={"known_limit": True},
metadata={
"help_contexts": [
"records.lifecycle",
"records.lifecycle.volume",
"records.lifecycle.close",
"records.lifecycle.reopen",
"records.lifecycle.appraise",
"records.lifecycle.hold",
"records.lifecycle.release-hold",
"records.lifecycle.disposition",
"records.lifecycle.finalize",
"records.lifecycle.prepare-transfer",
"records.lifecycle.dispatch-transfer",
"records.lifecycle.recovery",
"records.catalog.admin",
"records.field.volume",
"records.field.volume-label",
"records.field.retention-trigger",
"records.field.disposition",
"records.field.hold-authority",
"records.field.lifecycle-reason",
"records.field.archive-provider",
"records.field.archive-profile",
],
},
),
)
@@ -361,14 +430,25 @@ manifest = ModuleManifest(
provides_interfaces=(
ModuleInterfaceProvider(name="records.registry", version="1.0.0"),
ModuleInterfaceProvider(name="records.filing", version="1.0.0"),
ModuleInterfaceProvider(name="records.archive", version="1.0.0"),
),
capability_factories={CAPABILITY_RECORDS_FILING: _records_registry},
capability_factories={
CAPABILITY_RECORDS_FILING: _records_registry,
record_archive_capability(SIMULATION_PROVIDER_ID): _simulated_archive_provider,
},
capability_documentation={
CAPABILITY_RECORDS_FILING: CapabilityDocumentation(
label="Record filing",
summary="Resolves authorized exact source revisions and files immutable record items.",
contract_version="1.0.0",
),
record_archive_capability(SIMULATION_PROVIDER_ID): CapabilityDocumentation(
label="Record archive simulation",
summary=(
"Validates archive-neutral package and receipt handling without transferring custody."
),
contract_version="1.0.0",
),
},
migration_spec=MigrationSpec(
module_id=MODULE_ID,
@@ -377,6 +457,9 @@ manifest = ModuleManifest(
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
record_models.RecordChronologyEntry,
record_models.RecordTransferPackageRevision,
record_models.RecordDispositionRevision,
record_models.RecordHoldRevision,
record_models.RecordItem,
record_models.RecordVolumeRevision,
record_models.RecordRevision,
@@ -396,6 +479,9 @@ manifest = ModuleManifest(
record_models.RecordRevision,
record_models.RecordItem,
record_models.RecordChronologyEntry,
record_models.RecordHoldRevision,
record_models.RecordDispositionRevision,
record_models.RecordTransferPackageRevision,
record_models.RecordClassRevision,
record_models.RecordFilePlanRevision,
label="Records",
@@ -417,6 +503,9 @@ manifest = ModuleManifest(
"record_item",
"record_class",
"file_plan_node",
"record_hold",
"record_disposition",
"record_transfer_package",
),
evidence=(
"src/govoplan_records/backend/service.py",
@@ -433,10 +522,16 @@ manifest = ModuleManifest(
),
),
retention=InformationGovernanceDimension(
adoption="contract_only",
limitation=(
"Record classes preserve retention inputs; closure, holds, calculation, appraisal, "
"and disposition are tracked in Records #5."
adoption="enforced",
object_types=(
"record",
"record_class",
"record_hold",
"record_disposition",
),
evidence=(
"src/govoplan_records/backend/service.py",
"tests/test_records.py",
),
),
institutional_context=InformationGovernanceDimension(
@@ -462,8 +557,8 @@ manifest = ModuleManifest(
documentation_ref="docs/EAKTE_ARCHITECTURE.md",
test_ref="tests/test_records.py",
known_limits=(
"Restricted object grants and lifecycle stages after open are tracked separately.",
"Archive transfer and destructive effects are not part of the native kernel.",
"Restricted object grants are tracked separately.",
"Archive packaging is native, but real target conformance and destructive effects remain external work.",
),
supported_authority_modes=(
"native_authoritative",
@@ -481,6 +576,10 @@ manifest = ModuleManifest(
"record item",
"filing decision",
"record chronology",
"record hold",
"record appraisal",
"record disposition",
"record transfer package",
),
non_owned_concepts=(
"file content",
@@ -0,0 +1,291 @@
"""Add governed Records lifecycle evidence.
Revision ID: 7f5b3d9a2c1e
Revises: 6e4a2c8f1d9b
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "7f5b3d9a2c1e"
down_revision = "6e4a2c8f1d9b"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("record_revisions") as batch_op:
batch_op.add_column(
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True)
)
batch_op.add_column(
sa.Column("retention_started_at", sa.DateTime(timezone=True), nullable=True)
)
batch_op.add_column(
sa.Column("retention_due_at", sa.DateTime(timezone=True), nullable=True)
)
batch_op.add_column(
sa.Column(
"retention_rule",
sa.JSON(),
nullable=False,
server_default=sa.text("'{}'"),
)
)
batch_op.add_column(
sa.Column("appraisal_state", sa.String(length=40), nullable=True)
)
batch_op.add_column(
sa.Column(
"appraisal",
sa.JSON(),
nullable=False,
server_default=sa.text("'{}'"),
)
)
batch_op.create_index(
op.f("ix_record_revisions_closed_at"), ["closed_at"], unique=False
)
batch_op.create_index(
op.f("ix_record_revisions_retention_started_at"),
["retention_started_at"],
unique=False,
)
batch_op.create_index(
op.f("ix_record_revisions_retention_due_at"),
["retention_due_at"],
unique=False,
)
batch_op.create_index(
op.f("ix_record_revisions_appraisal_state"),
["appraisal_state"],
unique=False,
)
op.create_table(
"record_hold_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("hold_id", sa.String(length=255), nullable=False),
sa.Column("record_id", sa.String(length=255), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column("authority", sa.String(length=500), nullable=False),
sa.Column("scope", sa.JSON(), nullable=False),
sa.Column("effective_from", sa.DateTime(timezone=True), nullable=False),
sa.Column("effective_to", sa.DateTime(timezone=True), nullable=True),
sa.Column("released_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("policy_refs", sa.JSON(), nullable=False),
sa.Column("institutional_context", sa.JSON(), nullable=False),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("changed_by", sa.String(length=255), nullable=True),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["previous_revision_id"],
["record_hold_revisions.id"],
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id", "hold_id", "revision", name="uq_record_hold_revision"
),
sa.UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_record_hold_idempotency"
),
)
_indexes(
"record_hold_revisions",
"tenant_id",
"hold_id",
"record_id",
"previous_revision_id",
"status",
"effective_from",
"effective_to",
"recorded_at",
"superseded_at",
"changed_by",
)
op.create_index(
"ix_record_hold_current",
"record_hold_revisions",
["tenant_id", "hold_id", "superseded_at"],
)
op.create_index(
"ix_record_hold_record",
"record_hold_revisions",
["tenant_id", "record_id", "status"],
)
op.create_table(
"record_disposition_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("disposition_id", sa.String(length=255), nullable=False),
sa.Column("record_id", sa.String(length=255), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("action", sa.String(length=30), nullable=False),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column("subject_revision", sa.Integer(), nullable=False),
sa.Column("subject_sha256", sa.String(length=64), nullable=False),
sa.Column("consequence_preview", sa.JSON(), nullable=False),
sa.Column("policy_refs", sa.JSON(), nullable=False),
sa.Column("approval_request_id", sa.String(length=255), nullable=True),
sa.Column("proposed_by", sa.String(length=255), nullable=True),
sa.Column("reviewed_by", sa.String(length=255), nullable=True),
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("institutional_context", sa.JSON(), nullable=False),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["previous_revision_id"],
["record_disposition_revisions.id"],
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id",
"disposition_id",
"revision",
name="uq_record_disposition_revision",
),
sa.UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_record_disposition_idempotency",
),
)
_indexes(
"record_disposition_revisions",
"tenant_id",
"disposition_id",
"record_id",
"previous_revision_id",
"action",
"status",
"approval_request_id",
"proposed_by",
"reviewed_by",
"recorded_at",
"superseded_at",
)
op.create_index(
"ix_record_disposition_current",
"record_disposition_revisions",
["tenant_id", "disposition_id", "superseded_at"],
)
op.create_index(
"ix_record_disposition_record",
"record_disposition_revisions",
["tenant_id", "record_id", "status"],
)
op.create_table(
"record_transfer_package_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("package_id", sa.String(length=255), nullable=False),
sa.Column("record_id", sa.String(length=255), nullable=False),
sa.Column("disposition_id", sa.String(length=255), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("record_revision", sa.Integer(), nullable=False),
sa.Column("provider_id", sa.String(length=100), nullable=False),
sa.Column("profile", sa.String(length=255), nullable=False),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("authority_mode", sa.String(length=40), nullable=False),
sa.Column("manifest", sa.JSON(), nullable=False),
sa.Column("manifest_sha256", sa.String(length=64), nullable=False),
sa.Column("receipt", sa.JSON(), nullable=False),
sa.Column("receipt_sha256", sa.String(length=64), nullable=True),
sa.Column("external_reference", sa.String(length=1500), nullable=True),
sa.Column("recovery_operation_id", sa.String(length=36), nullable=True),
sa.Column("simulated", sa.Boolean(), nullable=False),
sa.Column("institutional_context", sa.JSON(), nullable=False),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("changed_by", sa.String(length=255), nullable=True),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["previous_revision_id"],
["record_transfer_package_revisions.id"],
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id",
"package_id",
"revision",
name="uq_record_transfer_package_revision",
),
sa.UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_record_transfer_package_idempotency",
),
)
_indexes(
"record_transfer_package_revisions",
"tenant_id",
"package_id",
"record_id",
"disposition_id",
"previous_revision_id",
"provider_id",
"status",
"recovery_operation_id",
"recorded_at",
"superseded_at",
"changed_by",
)
op.create_index(
"ix_record_transfer_package_current",
"record_transfer_package_revisions",
["tenant_id", "package_id", "superseded_at"],
)
op.create_index(
"ix_record_transfer_package_record",
"record_transfer_package_revisions",
["tenant_id", "record_id", "status"],
)
def downgrade() -> None:
op.drop_table("record_transfer_package_revisions")
op.drop_table("record_disposition_revisions")
op.drop_table("record_hold_revisions")
with op.batch_alter_table("record_revisions") as batch_op:
batch_op.drop_index(op.f("ix_record_revisions_appraisal_state"))
batch_op.drop_index(op.f("ix_record_revisions_retention_due_at"))
batch_op.drop_index(op.f("ix_record_revisions_retention_started_at"))
batch_op.drop_index(op.f("ix_record_revisions_closed_at"))
batch_op.drop_column("appraisal")
batch_op.drop_column("appraisal_state")
batch_op.drop_column("retention_rule")
batch_op.drop_column("retention_due_at")
batch_op.drop_column("retention_started_at")
batch_op.drop_column("closed_at")
def _indexes(table: str, *columns: str) -> None:
for column in columns:
op.create_index(op.f(f"ix_{table}_{column}"), table, [column], unique=False)
+203
View File
@@ -0,0 +1,203 @@
from __future__ import annotations
from dataclasses import dataclass
from contextvars import ContextVar, Token
import hashlib
import json
from typing import Any
from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.core.recovery import (
RecoveryGuaranteeError,
RecoveryMode,
RecoveryPlan,
)
from govoplan_core.core.recovery_runtime import (
DurableRecoveryOperation,
RecoveryOperationBusy,
RecoveryOperationStateConflict,
begin_durable_recovery_operation,
)
from govoplan_core.core.runtime_coordination import process_runtime_identity
class RecordRecoveryError(RuntimeError):
pass
_recovery_operation_id: ContextVar[str | None] = ContextVar(
"records_recovery_operation_id", default=None
)
def bind_record_recovery_operation(operation_id: str) -> Token[str | None]:
return _recovery_operation_id.set(operation_id)
def current_record_recovery_operation() -> str | None:
return _recovery_operation_id.get()
def reset_record_recovery_operation(token: Token[str | None]) -> None:
_recovery_operation_id.reset(token)
def record_session_factory(session: Session) -> sessionmaker[Session]:
bind = session.get_bind()
if bind is None:
raise RecordRecoveryError("Records recovery requires a bound database session.")
return sessionmaker(bind=bind, expire_on_commit=False)
@dataclass(slots=True)
class RecordAtomicRecovery:
operation: DurableRecoveryOperation | None
operation_id: str
replayed: bool
def commit_success(
self,
session: Session,
*,
result: object,
resource_id: str,
) -> None:
evidence = {
"verified": True,
"resource_id": resource_id,
"result_sha256": _canonical_sha256(result),
"domain_and_checkpoint_atomic": True,
"checks": {
"domain_result_digest_recorded": True,
"domain_and_checkpoint_atomic": True,
},
}
if self.operation is None:
session.commit()
return
self.operation.commit_atomic_success(session, evidence=evidence)
def reject(self, *, summary: str, error_type: str) -> None:
if self.operation is None:
return
self.operation.reject(
summary=summary,
evidence={
"verified": True,
"domain_mutation_committed": False,
"error_type": error_type,
"checks": {"definitive_domain_rejection": True},
},
)
def fail(self, *, summary: str, error_type: str) -> None:
if self.operation is None:
return
self.operation.fail(
summary=summary,
evidence={
"verified": True,
"domain_mutation_committed": False,
"error_type": error_type,
},
)
def begin_record_atomic_recovery(
session: Session,
*,
tenant_id: str,
operation_type: str,
idempotency_key: str,
request: dict[str, Any],
resource_type: str,
resource_id: str,
) -> RecordAtomicRecovery:
operation_key = hashlib.sha256(
f"{tenant_id}:{operation_type}:{idempotency_key}".encode("utf-8")
).hexdigest()
lease_id = hashlib.sha256(
f"{tenant_id}:{resource_type}:{resource_id}".encode("utf-8")
).hexdigest()
try:
started = begin_durable_recovery_operation(
record_session_factory(session),
identity=process_runtime_identity(),
module_id="records",
operation_type=operation_type,
idempotency_key=f"records:{operation_key}",
request={"tenant_id": tenant_id, **request},
recovery_plan=RecoveryPlan(
mode=RecoveryMode.ATOMIC,
preconditions=(
"the current actor is authorized for the Records mutation",
"the expected revision and idempotency key are present",
"the target resource has no unresolved recovery operation",
),
verification_steps=(
"commit the immutable domain revision and chronology entry",
"commit the terminal recovery checkpoint in the same transaction",
"verify the result digest and recovery evidence chain",
),
),
precondition_evidence={
"tenant_id": tenant_id,
"resource_type": resource_type,
"resource_id_sha256": hashlib.sha256(
resource_id.encode("utf-8")
).hexdigest(),
"request_sha256": _canonical_sha256(request),
"external_effect": False,
},
lease_resource_key=f"records:{tenant_id}:{lease_id}",
lease_ttl_seconds=5 * 60,
resource_type=resource_type,
resource_id=resource_id,
metadata={
"resources": ["postgresql"],
"external_effect": False,
"recovery_declaration": "records-atomic-revision",
},
block_unresolved_resource=True,
)
except RecoveryOperationBusy as exc:
raise RecordRecoveryError(
"Another runtime is changing this Records resource."
) from exc
except RecoveryOperationStateConflict as exc:
raise RecordRecoveryError(
"This Records resource has an active or unresolved recovery operation."
) from exc
except (RecoveryGuaranteeError, RuntimeError) as exc:
raise RecordRecoveryError(
"The recovery ledger is unavailable; the Records mutation was not started."
) from exc
return RecordAtomicRecovery(
operation=started.operation,
operation_id=started.operation_id,
replayed=started.replayed,
)
def _canonical_sha256(value: object) -> str:
return hashlib.sha256(
json.dumps(
value,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
default=str,
).encode("utf-8")
).hexdigest()
__all__ = [
"RecordAtomicRecovery",
"RecordRecoveryError",
"begin_record_atomic_recovery",
"bind_record_recovery_operation",
"current_record_recovery_operation",
"record_session_factory",
"reset_record_recovery_operation",
]
+419 -3
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import hashlib
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -7,18 +8,31 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.core.records import RecordFilingRequest, RecordSourceLocator
from govoplan_core.db.session import get_session
from govoplan_records.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE
from govoplan_records.backend.schemas import (
FilePlanNodeWriteRequest,
RecordAppraisalRequest,
RecordArchiveProviderResponse,
RecordCatalogResponse,
RecordClassWriteRequest,
RecordCloseRequest,
RecordCreateRequest,
RecordDetailResponse,
RecordDispositionCreateRequest,
RecordDispositionFinalizeRequest,
RecordDispositionWithdrawRequest,
RecordHoldCreateRequest,
RecordHoldReleaseRequest,
RecordItemCreateRequest,
RecordLifecycleActionRequest,
RecordListResponse,
RecordRecoveryStatusResponse,
RecordSourceProviderResponse,
RecordTransferDispatchRequest,
RecordTransferPackageCreateRequest,
RecordUpdateRequest,
RecordVolumeCreateRequest,
)
@@ -29,6 +43,12 @@ from govoplan_records.backend.service import (
RecordStoreError,
SqlRecordRegistry,
)
from govoplan_records.backend.recovery import (
RecordRecoveryError,
begin_record_atomic_recovery,
bind_record_recovery_operation,
reset_record_recovery_operation,
)
def create_router(registry: object | None = None) -> APIRouter:
@@ -59,6 +79,12 @@ def create_router(registry: object | None = None) -> APIRouter:
lambda: records.write_file_plan_node(
session, principal, payload=payload.model_dump(mode="python")
),
principal=principal,
operation_type="catalog.file_plan.write",
idempotency_key=payload.idempotency_key,
request=payload.model_dump(mode="json"),
resource_type="record_file_plan_node",
resource_id=payload.node_id,
)
@router.post(
@@ -77,6 +103,12 @@ def create_router(registry: object | None = None) -> APIRouter:
lambda: records.write_record_class(
session, principal, payload=payload.model_dump(mode="python")
),
principal=principal,
operation_type="catalog.class.write",
idempotency_key=payload.idempotency_key,
request=payload.model_dump(mode="json"),
resource_type="record_class",
resource_id=payload.class_id,
)
@router.get("/sources", response_model=RecordSourceProviderResponse)
@@ -89,6 +121,16 @@ def create_router(registry: object | None = None) -> APIRouter:
providers=records.source_providers(session, principal)
)
@router.get("/archive-providers", response_model=RecordArchiveProviderResponse)
def api_archive_providers(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> RecordArchiveProviderResponse:
_require(principal, WRITE_SCOPE)
return RecordArchiveProviderResponse(
providers=records.archive_providers(session, principal)
)
@router.get("", response_model=RecordListResponse)
def api_list_records(
query: str | None = Query(default=None, max_length=500),
@@ -127,6 +169,12 @@ def create_router(registry: object | None = None) -> APIRouter:
lambda: records.create_record(
session, principal, payload=payload.model_dump(mode="python")
),
principal=principal,
operation_type="record.create",
idempotency_key=payload.idempotency_key,
request=payload.model_dump(mode="json"),
resource_type="record",
resource_id=payload.record_id or payload.record_number,
)
@router.get("/{record_id}", response_model=RecordDetailResponse)
@@ -146,6 +194,20 @@ def create_router(registry: object | None = None) -> APIRouter:
except RecordStoreError as exc:
raise _http_error(exc) from exc
@router.get("/{record_id}/recovery", response_model=RecordRecoveryStatusResponse)
def api_record_recovery_status(
record_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> RecordRecoveryStatusResponse:
_require(principal, ADMIN_SCOPE)
try:
return RecordRecoveryStatusResponse(
**records.recovery_status(session, principal, record_id=record_id)
)
except RecordStoreError as exc:
raise _http_error(exc) from exc
@router.patch("/{record_id}", response_model=dict[str, Any])
def api_update_record(
record_id: str,
@@ -162,6 +224,294 @@ def create_router(registry: object | None = None) -> APIRouter:
record_id=record_id,
payload=payload.model_dump(mode="python", exclude_unset=True),
),
principal=principal,
operation_type="record.revise",
idempotency_key=payload.idempotency_key,
request=payload.model_dump(mode="json", exclude_unset=True),
resource_type="record",
resource_id=record_id,
)
@router.post("/{record_id}/close", response_model=dict[str, Any])
def api_close_record(
record_id: str,
payload: RecordCloseRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, WRITE_SCOPE)
if payload.restart_retention:
_require(principal, ADMIN_SCOPE)
return _write(
session,
lambda: records.close_record(
session,
principal,
record_id=record_id,
payload=payload.model_dump(mode="python"),
),
principal=principal,
operation_type="record.close",
idempotency_key=payload.idempotency_key,
request=payload.model_dump(mode="json"),
resource_type="record",
resource_id=record_id,
)
@router.post("/{record_id}/reopen", response_model=dict[str, Any])
def api_reopen_record(
record_id: str,
payload: RecordLifecycleActionRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, WRITE_SCOPE)
return _write(
session,
lambda: records.reopen_record(
session,
principal,
record_id=record_id,
payload=payload.model_dump(mode="python"),
),
principal=principal,
operation_type="record.reopen",
idempotency_key=payload.idempotency_key,
request=payload.model_dump(mode="json"),
resource_type="record",
resource_id=record_id,
)
@router.post("/{record_id}/appraise", response_model=dict[str, Any])
def api_appraise_record(
record_id: str,
payload: RecordAppraisalRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, WRITE_SCOPE)
if payload.override_retention_not_due:
_require(principal, ADMIN_SCOPE)
return _write(
session,
lambda: records.appraise_record(
session,
principal,
record_id=record_id,
payload=payload.model_dump(mode="python"),
),
principal=principal,
operation_type="record.appraise",
idempotency_key=payload.idempotency_key,
request=payload.model_dump(mode="json"),
resource_type="record",
resource_id=record_id,
)
@router.post(
"/{record_id}/holds",
response_model=dict[str, Any],
status_code=status.HTTP_201_CREATED,
)
def api_apply_hold(
record_id: str,
payload: RecordHoldCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, WRITE_SCOPE)
return _write(
session,
lambda: records.apply_hold(
session,
principal,
record_id=record_id,
payload=payload.model_dump(mode="python"),
),
principal=principal,
operation_type="hold.apply",
idempotency_key=payload.idempotency_key,
request=payload.model_dump(mode="json"),
resource_type="record",
resource_id=record_id,
)
@router.post(
"/{record_id}/holds/{hold_id}/release",
response_model=dict[str, Any],
)
def api_release_hold(
record_id: str,
hold_id: str,
payload: RecordHoldReleaseRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, WRITE_SCOPE)
return _write(
session,
lambda: records.release_hold(
session,
principal,
record_id=record_id,
hold_id=hold_id,
payload=payload.model_dump(mode="python"),
),
principal=principal,
operation_type="hold.release",
idempotency_key=payload.idempotency_key,
request={"hold_id": hold_id, **payload.model_dump(mode="json")},
resource_type="record",
resource_id=record_id,
)
@router.post(
"/{record_id}/dispositions",
response_model=dict[str, Any],
status_code=status.HTTP_201_CREATED,
)
def api_propose_disposition(
record_id: str,
payload: RecordDispositionCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, WRITE_SCOPE)
return _write(
session,
lambda: records.propose_disposition(
session,
principal,
record_id=record_id,
payload=payload.model_dump(mode="python"),
),
principal=principal,
operation_type="disposition.propose",
idempotency_key=payload.idempotency_key,
request=payload.model_dump(mode="json"),
resource_type="record",
resource_id=record_id,
)
@router.post(
"/{record_id}/dispositions/{disposition_id}/finalize",
response_model=dict[str, Any],
)
def api_finalize_disposition(
record_id: str,
disposition_id: str,
payload: RecordDispositionFinalizeRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, WRITE_SCOPE)
return _write(
session,
lambda: records.finalize_disposition(
session,
principal,
record_id=record_id,
disposition_id=disposition_id,
payload=payload.model_dump(mode="python"),
),
principal=principal,
operation_type="disposition.finalize",
idempotency_key=payload.idempotency_key,
request={
"disposition_id": disposition_id,
**payload.model_dump(mode="json"),
},
resource_type="record",
resource_id=record_id,
)
@router.post(
"/{record_id}/dispositions/{disposition_id}/withdraw",
response_model=dict[str, Any],
)
def api_withdraw_disposition(
record_id: str,
disposition_id: str,
payload: RecordDispositionWithdrawRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, WRITE_SCOPE)
return _write(
session,
lambda: records.withdraw_disposition(
session,
principal,
record_id=record_id,
disposition_id=disposition_id,
payload=payload.model_dump(mode="python"),
),
principal=principal,
operation_type="disposition.withdraw",
idempotency_key=payload.idempotency_key,
request={
"disposition_id": disposition_id,
**payload.model_dump(mode="json"),
},
resource_type="record",
resource_id=record_id,
)
@router.post(
"/{record_id}/transfer-packages",
response_model=dict[str, Any],
status_code=status.HTTP_201_CREATED,
)
def api_prepare_transfer_package(
record_id: str,
payload: RecordTransferPackageCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, WRITE_SCOPE)
return _write(
session,
lambda: records.prepare_transfer_package(
session,
principal,
record_id=record_id,
payload=payload.model_dump(mode="python"),
),
principal=principal,
operation_type="transfer.prepare",
idempotency_key=payload.idempotency_key,
request=payload.model_dump(mode="json"),
resource_type="record",
resource_id=record_id,
)
@router.post(
"/{record_id}/transfer-packages/{package_id}/dispatch",
response_model=dict[str, Any],
)
def api_dispatch_transfer_package(
record_id: str,
package_id: str,
payload: RecordTransferDispatchRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, WRITE_SCOPE)
return _write(
session,
lambda: records.dispatch_transfer_package(
session,
principal,
record_id=record_id,
package_id=package_id,
payload=payload.model_dump(mode="python"),
),
principal=principal,
operation_type="transfer.simulate",
idempotency_key=payload.idempotency_key,
request={"package_id": package_id, **payload.model_dump(mode="json")},
resource_type="record",
resource_id=record_id,
)
@router.post(
@@ -184,6 +534,12 @@ def create_router(registry: object | None = None) -> APIRouter:
record_id=record_id,
payload=payload.model_dump(mode="python"),
),
principal=principal,
operation_type="volume.create",
idempotency_key=payload.idempotency_key,
request=payload.model_dump(mode="json"),
resource_type="record",
resource_id=record_id,
)
@router.post(
@@ -235,7 +591,16 @@ def create_router(registry: object | None = None) -> APIRouter:
},
}
return _write(session, operation)
return _write(
session,
operation,
principal=principal,
operation_type="item.file",
idempotency_key=payload.idempotency_key,
request=payload.model_dump(mode="json"),
resource_type="record",
resource_id=record_id,
)
return router
@@ -245,18 +610,69 @@ def _require(principal: ApiPrincipal, scope: str) -> None:
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
def _write(session: Session, operation):
def _write(
session: Session,
operation,
*,
principal: ApiPrincipal,
operation_type: str,
idempotency_key: str,
request: dict[str, Any],
resource_type: str,
resource_id: str,
):
recovery = None
try:
recovery = begin_record_atomic_recovery(
session,
tenant_id=principal.tenant_id,
operation_type=operation_type,
idempotency_key=idempotency_key,
request=request,
resource_type=resource_type,
resource_id=resource_id,
)
recovery_token = bind_record_recovery_operation(recovery.operation_id)
try:
result = operation()
session.commit()
finally:
reset_record_recovery_operation(recovery_token)
if not recovery.replayed:
audit_from_principal(
session,
principal,
action=f"records.{operation_type}",
object_type=resource_type,
object_id=resource_id,
details={
"recovery_operation_id": recovery.operation_id,
"idempotency_key_sha256": _sha256(idempotency_key),
},
commit=False,
)
recovery.commit_success(session, result=result, resource_id=resource_id)
return result
except (RecordStoreError, IntegrityError) as exc:
session.rollback()
if recovery is not None:
recovery.reject(summary=str(exc), error_type=type(exc).__name__)
if isinstance(exc, IntegrityError):
raise HTTPException(
status_code=409, detail="The record write conflicts with existing data."
) from exc
raise _http_error(exc) from exc
except RecordRecoveryError as exc:
session.rollback()
raise HTTPException(status_code=503, detail=str(exc)) from exc
except Exception as exc:
session.rollback()
if recovery is not None:
recovery.fail(summary=str(exc), error_type=type(exc).__name__)
raise
def _sha256(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def _http_error(exc: RecordStoreError) -> HTTPException:
+124
View File
@@ -148,6 +148,99 @@ class RecordItemCreateRequest(StrictModel):
metadata: dict[str, Any] = Field(default_factory=dict)
class RecordLifecycleActionRequest(StrictModel):
expected_revision: int = Field(ge=1)
purpose: str = Field(min_length=1, max_length=255)
reason: str = Field(min_length=1, max_length=2_000)
recorded_at: datetime
idempotency_key: str = Field(min_length=1, max_length=255)
class RecordCloseRequest(RecordLifecycleActionRequest):
retention_trigger_at: datetime | None = None
restart_retention: bool = False
class RecordAppraisalRequest(RecordLifecycleActionRequest):
outcome: Literal["retain", "transfer", "destroy", "reclassify"]
policy_refs: list[str] = Field(default_factory=list, max_length=100)
override_retention_not_due: bool = False
class RecordHoldCreateRequest(StrictModel):
hold_id: str | None = Field(default=None, max_length=255)
expected_record_revision: int = Field(ge=1)
reason: str = Field(min_length=1, max_length=10_000)
authority: str = Field(min_length=1, max_length=500)
purpose: str = Field(min_length=1, max_length=255)
scope: dict[str, Any] = Field(default_factory=dict)
effective_from: datetime | None = None
effective_to: datetime | None = None
policy_refs: list[str] = Field(default_factory=list, max_length=100)
institutional_context: dict[str, Any] = Field(default_factory=dict)
recorded_at: datetime
idempotency_key: str = Field(min_length=1, max_length=255)
@model_validator(mode="after")
def validate_interval(self):
if (
self.effective_from
and self.effective_to
and self.effective_to <= self.effective_from
):
raise ValueError("effective_to must be after effective_from")
return self
class RecordHoldReleaseRequest(StrictModel):
expected_hold_revision: int = Field(ge=1)
reason: str = Field(min_length=1, max_length=2_000)
purpose: str = Field(min_length=1, max_length=255)
recorded_at: datetime
idempotency_key: str = Field(min_length=1, max_length=255)
class RecordDispositionCreateRequest(StrictModel):
disposition_id: str | None = Field(default=None, max_length=255)
expected_record_revision: int = Field(ge=1)
action: Literal["retain", "transfer", "destroy", "reclassify"]
reason: str = Field(min_length=1, max_length=10_000)
purpose: str = Field(min_length=1, max_length=255)
policy_refs: list[str] = Field(default_factory=list, max_length=100)
institutional_context: dict[str, Any] = Field(default_factory=dict)
recorded_at: datetime
idempotency_key: str = Field(min_length=1, max_length=255)
class RecordDispositionFinalizeRequest(StrictModel):
expected_disposition_revision: int = Field(ge=1)
purpose: str = Field(min_length=1, max_length=255)
recorded_at: datetime
idempotency_key: str = Field(min_length=1, max_length=255)
class RecordDispositionWithdrawRequest(RecordDispositionFinalizeRequest):
reason: str = Field(min_length=1, max_length=2_000)
class RecordTransferPackageCreateRequest(StrictModel):
package_id: str | None = Field(default=None, max_length=255)
disposition_id: str = Field(min_length=1, max_length=255)
expected_record_revision: int = Field(ge=1)
provider_id: str = Field(min_length=1, max_length=100)
profile: str = Field(min_length=1, max_length=255)
purpose: str = Field(min_length=1, max_length=255)
recorded_at: datetime
idempotency_key: str = Field(min_length=1, max_length=255)
class RecordTransferDispatchRequest(StrictModel):
expected_package_revision: int = Field(ge=1)
purpose: str = Field(min_length=1, max_length=255)
recorded_at: datetime
idempotency_key: str = Field(min_length=1, max_length=255)
class RecordListResponse(StrictModel):
records: list[dict[str, Any]]
total: int
@@ -165,6 +258,9 @@ class RecordDetailResponse(StrictModel):
volumes: list[dict[str, Any]]
items: list[dict[str, Any]]
chronology: list[dict[str, Any]]
holds: list[dict[str, Any]]
dispositions: list[dict[str, Any]]
transfer_packages: list[dict[str, Any]]
access_explanation: dict[str, Any]
@@ -172,15 +268,43 @@ class RecordSourceProviderResponse(StrictModel):
providers: list[dict[str, Any]]
class RecordArchiveProviderResponse(StrictModel):
providers: list[dict[str, Any]]
class RecordRecoveryStatusResponse(StrictModel):
record_id: str
record_revision: int
evidence_sha256: str
healthy: bool
source_checks: list[dict[str, Any]]
package_checks: list[dict[str, Any]]
recovery_operations: list[dict[str, Any]]
failure_count: int
limitations: list[str]
__all__ = [
"FilePlanNodeWriteRequest",
"RecordAppraisalRequest",
"RecordArchiveProviderResponse",
"RecordCatalogResponse",
"RecordClassWriteRequest",
"RecordCloseRequest",
"RecordCreateRequest",
"RecordDetailResponse",
"RecordDispositionCreateRequest",
"RecordDispositionFinalizeRequest",
"RecordDispositionWithdrawRequest",
"RecordHoldCreateRequest",
"RecordHoldReleaseRequest",
"RecordItemCreateRequest",
"RecordLifecycleActionRequest",
"RecordListResponse",
"RecordRecoveryStatusResponse",
"RecordSourceProviderResponse",
"RecordTransferDispatchRequest",
"RecordTransferPackageCreateRequest",
"RecordUpdateRequest",
"RecordVolumeCreateRequest",
]
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"id": "assisted-service-to-decision-record",
"description": "A completed assisted service is filed, closed, held, appraised, independently approved, and exercised through the archive simulation boundary.",
"record": {
"record_id": "record-1",
"record_number": "2026/0001",
"class_id": "class-permit"
},
"expected": {
"closed_state": "retention_running",
"appraised_state": "appraised",
"approved_state": "transfer_pending",
"hold_blocks_disposition": true,
"disposition_status": "approved",
"transfer_status": "simulated_accepted",
"custody_transferred": false
}
}
+4 -1
View File
@@ -30,9 +30,12 @@ class RecordsMigrationTests(unittest.TestCase):
"record_chronology_entries",
"record_class_revisions",
"record_file_plan_revisions",
"record_hold_revisions",
"record_identities",
"record_items",
"record_disposition_revisions",
"record_revisions",
"record_transfer_package_revisions",
"record_volume_revisions",
}.issubset(inspector.get_table_names())
)
@@ -42,7 +45,7 @@ class RecordsMigrationTests(unittest.TestCase):
)
with engine.connect() as connection:
self.assertIn(
"6e4a2c8f1d9b",
"7f5b3d9a2c1e",
set(MigrationContext.configure(connection).get_current_heads()),
)
finally:
+416 -5
View File
@@ -2,16 +2,22 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
import json
from pathlib import Path
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.records import (
RecordArchiveProviderState,
RecordArchiveReceipt,
RecordFilingRequest,
RecordSourceLocator,
RecordSourceReference,
)
from govoplan_core.core.recovery import RecoveryCheckpoint, RecoveryOperation
from govoplan_core.core.approvals import ApprovalCheck, ApprovalRequestRef
from govoplan_core.core.temporal import (
TemporalDataContext,
bind_temporal_data_context,
@@ -20,12 +26,16 @@ from govoplan_core.core.temporal import (
from govoplan_records.backend.db.models import (
RecordChronologyEntry,
RecordClassRevision,
RecordDispositionRevision,
RecordFilePlanRevision,
RecordHoldRevision,
RecordIdentity,
RecordItem,
RecordRevision,
RecordTransferPackageRevision,
RecordVolumeRevision,
)
from govoplan_records.backend.archive import SimulatedRecordArchiveProvider
from govoplan_records.backend.service import RecordConflictError, SqlRecordRegistry
@@ -63,21 +73,114 @@ class SourceProvider:
)
class UnknownOutcomeArchiveProvider:
provider_id = "unknown_simulation"
def __init__(self) -> None:
self.dispatch_count = 0
def state(self):
return RecordArchiveProviderState(
provider_id=self.provider_id,
label="Unknown-outcome simulation",
profiles=("govoplan-unknown-outcome-v1",),
authority_modes=("linked_reference",),
healthy=True,
checked_at=NOW,
simulated=True,
)
def dispatch(self, session, principal, *, request):
del session, principal
self.dispatch_count += 1
return RecordArchiveReceipt(
provider_id=self.provider_id,
package_id=request.package.package_id,
outcome="outcome_unknown",
observed_at=NOW + timedelta(minutes=12),
receipt_sha256="b" * 64,
retry_safe=False,
simulated=True,
metadata={"custody_transferred": False},
)
class Registry:
def __init__(self) -> None:
self.provider = SourceProvider()
self.archive_provider = SimulatedRecordArchiveProvider()
self.unknown_archive_provider = UnknownOutcomeArchiveProvider()
self.approvals = ApprovalProvider()
def capability_names(self):
return ("records.source.files",)
return (
"records.source.files",
"records.archive.simulation",
"records.archive.unknown_simulation",
)
def tenant_capability(self, name, session, *, tenant_id):
del session
return (
self.provider
if name == "records.source.files" and tenant_id == "tenant-1"
else None
if tenant_id != "tenant-1":
return None
return {
"records.source.files": self.provider,
"records.archive.simulation": self.archive_provider,
"records.archive.unknown_simulation": self.unknown_archive_provider,
"approvals.requests": self.approvals,
}.get(name)
class ApprovalProvider:
def __init__(self) -> None:
self.approved = False
self.request = None
def create_request(self, session, principal, *, command, idempotency_key):
del session, principal, idempotency_key
self.request = command
return ApprovalRequestRef(id="approval-1", revision=1, state="pending")
def check_approved(
self,
session,
principal,
*,
request_id,
subject_module,
subject_type,
subject_id,
subject_version,
subject_digest,
):
del session, principal
return ApprovalCheck(
request_id=request_id,
revision=2 if self.approved else 1,
state="approved" if self.approved else "pending",
approved=self.approved,
subject_module=subject_module,
subject_type=subject_type,
subject_id=subject_id,
subject_version=subject_version,
subject_digest=subject_digest,
)
def create_template(self, *args, **kwargs):
raise NotImplementedError
def revise_template(self, *args, **kwargs):
raise NotImplementedError
def publish_template(self, *args, **kwargs):
raise NotImplementedError
def get_request(self, *args, **kwargs):
return None
def decide(self, *args, **kwargs):
raise NotImplementedError
class RecordsTests(unittest.TestCase):
def setUp(self) -> None:
@@ -90,6 +193,11 @@ class RecordsTests(unittest.TestCase):
RecordVolumeRevision.__table__,
RecordItem.__table__,
RecordChronologyEntry.__table__,
RecordHoldRevision.__table__,
RecordDispositionRevision.__table__,
RecordTransferPackageRevision.__table__,
RecoveryOperation.__table__,
RecoveryCheckpoint.__table__,
):
table.create(self.engine)
self.session = Session(self.engine)
@@ -400,6 +508,309 @@ class RecordsTests(unittest.TestCase):
self.records.source_providers(self.session, self.principal),
)
def test_governed_lifecycle_hold_disposition_and_transfer_simulation(self) -> None:
fixture = json.loads(
(
Path(__file__).parent / "fixtures/service_to_decision_journey.json"
).read_text(encoding="utf-8")
)
expected = fixture["expected"]
self._create_record()
closed = self.records.close_record(
self.session,
self.principal,
record_id="record-1",
payload={
"expected_revision": 1,
"purpose": "close completed case record",
"reason": "The administrative decision is final.",
"recorded_at": NOW + timedelta(minutes=2),
"idempotency_key": "record-close-1",
},
)
self.assertEqual(expected["closed_state"], closed["state"])
self.assertEqual(
NOW + timedelta(minutes=2, days=3650),
datetime.fromisoformat(str(closed["retention_due_at"])),
)
appraised = self.records.appraise_record(
self.session,
self.principal,
record_id="record-1",
payload={
"expected_revision": 2,
"outcome": "transfer",
"purpose": "appraise completed record",
"reason": "Transfer to the institutional archive after review.",
"policy_refs": ["records-policy:v1"],
"override_retention_not_due": True,
"recorded_at": NOW + timedelta(minutes=3),
"idempotency_key": "record-appraise-1",
},
)
self.assertEqual(expected["appraised_state"], appraised["state"])
hold = self.records.apply_hold(
self.session,
self.principal,
record_id="record-1",
payload={
"expected_record_revision": 3,
"reason": "Pending judicial review.",
"authority": "Court order 2026-17",
"purpose": "preserve evidence",
"recorded_at": NOW + timedelta(minutes=4),
"idempotency_key": "record-hold-1",
},
)
with self.assertRaisesRegex(RecordConflictError, "active record hold"):
self.records.propose_disposition(
self.session,
self.principal,
record_id="record-1",
payload={
"expected_record_revision": 3,
"action": "transfer",
"reason": "Transfer after retention.",
"purpose": "dispose record",
"recorded_at": NOW + timedelta(minutes=5),
"idempotency_key": "record-disposition-blocked",
},
)
self.records.release_hold(
self.session,
self.principal,
record_id="record-1",
hold_id=str(hold["hold_id"]),
payload={
"expected_hold_revision": 1,
"reason": "Judicial review is complete.",
"purpose": "resume disposition",
"recorded_at": NOW + timedelta(minutes=6),
"idempotency_key": "record-hold-release-1",
},
)
disposition = self.records.propose_disposition(
self.session,
self.principal,
record_id="record-1",
payload={
"disposition_id": "disposition-1",
"expected_record_revision": 3,
"action": "transfer",
"reason": "Transfer after independent review.",
"purpose": "dispose record",
"policy_refs": ["records-policy:v1"],
"recorded_at": NOW + timedelta(minutes=7),
"idempotency_key": "record-disposition-1",
},
)
self.assertEqual("review_pending", disposition["status"])
self.assertFalse(disposition["consequence_preview"]["external_effect"])
self.records.registry.approvals.approved = True
finalized = self.records.finalize_disposition(
self.session,
self.principal,
record_id="record-1",
disposition_id="disposition-1",
payload={
"expected_disposition_revision": 1,
"purpose": "approve disposition",
"recorded_at": NOW + timedelta(minutes=8),
"idempotency_key": "record-disposition-finalize-1",
},
)
self.assertEqual(expected["approved_state"], finalized["record"]["state"])
package = self.records.prepare_transfer_package(
self.session,
self.principal,
record_id="record-1",
payload={
"package_id": "package-1",
"disposition_id": "disposition-1",
"expected_record_revision": 4,
"provider_id": "simulation",
"profile": "govoplan-simulation-v1",
"purpose": "validate archive transfer",
"recorded_at": NOW + timedelta(minutes=9),
"idempotency_key": "record-package-1",
},
)
self.assertEqual("prepared", package["status"])
receipt = self.records.dispatch_transfer_package(
self.session,
self.principal,
record_id="record-1",
package_id="package-1",
payload={
"expected_package_revision": 1,
"purpose": "validate archive transfer",
"recorded_at": NOW + timedelta(minutes=10),
"idempotency_key": "record-package-dispatch-1",
},
)
self.session.commit()
self.assertEqual(expected["transfer_status"], receipt["status"])
self.assertTrue(receipt["simulated"])
self.assertEqual(
expected["custody_transferred"],
receipt["receipt"]["metadata"]["custody_transferred"],
)
detail = self.records.get_record(
self.session, self.principal, record_id="record-1"
)
self.assertEqual("released", detail["holds"][0]["status"])
self.assertEqual(
expected["disposition_status"], detail["dispositions"][0]["status"]
)
self.assertEqual("simulated_accepted", detail["transfer_packages"][0]["status"])
unknown_package = self.records.prepare_transfer_package(
self.session,
self.principal,
record_id="record-1",
payload={
"package_id": "package-unknown",
"disposition_id": "disposition-1",
"expected_record_revision": 4,
"provider_id": "unknown_simulation",
"profile": "govoplan-unknown-outcome-v1",
"purpose": "exercise unknown transfer recovery",
"recorded_at": NOW + timedelta(minutes=11),
"idempotency_key": "record-package-unknown",
},
)
unknown_receipt = self.records.dispatch_transfer_package(
self.session,
self.principal,
record_id="record-1",
package_id="package-unknown",
payload={
"expected_package_revision": unknown_package["revision"],
"purpose": "exercise unknown transfer recovery",
"recorded_at": NOW + timedelta(minutes=12),
"idempotency_key": "record-package-unknown-dispatch",
},
)
self.assertEqual("outcome_unknown", unknown_receipt["status"])
self.assertFalse(unknown_receipt["receipt"]["retry_safe"])
with self.assertRaisesRegex(
ValueError, "unknown outcomes require reconciliation"
):
self.records.dispatch_transfer_package(
self.session,
self.principal,
record_id="record-1",
package_id="package-unknown",
payload={
"expected_package_revision": 2,
"purpose": "must not retry unknown transfer",
"recorded_at": NOW + timedelta(minutes=13),
"idempotency_key": "record-package-unknown-retry",
},
)
self.assertEqual(
1, self.records.registry.unknown_archive_provider.dispatch_count
)
self.assertEqual(
"transfer_pending",
self.records.get_record(self.session, self.principal, record_id="record-1")[
"record"
]["state"],
)
recovery = self.records.recovery_status(
self.session, self.principal, record_id="record-1"
)
self.assertTrue(recovery["healthy"])
self.assertTrue(recovery["package_checks"][0]["manifest_verified"])
def test_disposition_waits_for_optional_approvals_and_can_be_withdrawn(
self,
) -> None:
self.records.registry.approvals = None
self._create_record()
self.records.close_record(
self.session,
self.principal,
record_id="record-1",
payload={
"expected_revision": 1,
"purpose": "close record",
"reason": "Work completed.",
"recorded_at": NOW + timedelta(minutes=2),
"idempotency_key": "close-without-approvals",
},
)
self.records.appraise_record(
self.session,
self.principal,
record_id="record-1",
payload={
"expected_revision": 2,
"outcome": "retain",
"purpose": "appraise record",
"reason": "Retain permanently.",
"override_retention_not_due": True,
"recorded_at": NOW + timedelta(minutes=3),
"idempotency_key": "appraise-without-approvals",
},
)
proposal = self.records.propose_disposition(
self.session,
self.principal,
record_id="record-1",
payload={
"expected_record_revision": 3,
"action": "retain",
"purpose": "dispose record",
"reason": "Retain permanently.",
"recorded_at": NOW + timedelta(minutes=4),
"idempotency_key": "proposal-without-approvals",
},
)
self.assertEqual("review_unavailable", proposal["status"])
with self.assertRaisesRegex(ValueError, "not awaiting an available approval"):
self.records.finalize_disposition(
self.session,
self.principal,
record_id="record-1",
disposition_id=str(proposal["disposition_id"]),
payload={
"expected_disposition_revision": 1,
"purpose": "finalize disposition",
"recorded_at": NOW + timedelta(minutes=5),
"idempotency_key": "finalize-without-approvals",
},
)
withdrawn = self.records.withdraw_disposition(
self.session,
self.principal,
record_id="record-1",
disposition_id=str(proposal["disposition_id"]),
payload={
"expected_disposition_revision": 1,
"purpose": "correct disposition",
"reason": "The appraisal evidence needs correction.",
"recorded_at": NOW + timedelta(minutes=6),
"idempotency_key": "withdraw-without-approvals",
},
)
self.assertEqual("withdrawn", withdrawn["status"])
replacement = self.records.propose_disposition(
self.session,
self.principal,
record_id="record-1",
payload={
"expected_record_revision": 3,
"action": "retain",
"purpose": "dispose record",
"reason": "Corrected permanent-retention proposal.",
"recorded_at": NOW + timedelta(minutes=7),
"idempotency_key": "replacement-without-approvals",
},
)
self.assertEqual("review_unavailable", replacement["status"])
if __name__ == "__main__":
unittest.main()
+181
View File
@@ -0,0 +1,181 @@
from __future__ import annotations
from pathlib import Path
import sqlite3
import tempfile
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.recovery import (
RecoveryCheckpoint,
RecoveryOperation,
RecoveryStatus,
verify_recovery_evidence_chain,
)
from govoplan_core.core.runtime_coordination import (
DistributedLease,
RuntimeIdentity,
bind_process_runtime_identity,
)
from govoplan_records.backend.recovery import (
RecordRecoveryError,
begin_record_atomic_recovery,
)
class RecordsRecoveryTests(unittest.TestCase):
def setUp(self) -> None:
self.directory = tempfile.TemporaryDirectory(prefix="records-recovery-")
self.database_path = Path(self.directory.name) / "recovery.db"
self.engine = create_engine(f"sqlite:///{self.database_path}")
for table in (
DistributedLease.__table__,
RecoveryOperation.__table__,
RecoveryCheckpoint.__table__,
):
table.create(self.engine)
self.session = Session(self.engine)
bind_process_runtime_identity(
RuntimeIdentity(
installation_id="test-installation",
node_id="records-test-node",
incarnation="11111111-1111-4111-8111-111111111111",
role="api",
software_version="test",
composition_hash="a" * 64,
)
)
def tearDown(self) -> None:
bind_process_runtime_identity(None)
self.session.close()
self.engine.dispose()
self.directory.cleanup()
def test_atomic_evidence_chain_replays_only_the_same_request(self) -> None:
started = begin_record_atomic_recovery(
self.session,
tenant_id="tenant-1",
operation_type="record.close",
idempotency_key="close-1",
request={"record_id": "record-1", "expected_revision": 1},
resource_type="record",
resource_id="record-1",
)
started.commit_success(
self.session,
result={"record_id": "record-1", "revision": 2},
resource_id="record-1",
)
operation = self.session.get(RecoveryOperation, started.operation_id)
self.assertIsNotNone(operation)
self.assertEqual(RecoveryStatus.SUCCEEDED.value, operation.status)
self.assertTrue(
verify_recovery_evidence_chain(self.session, started.operation_id)
)
replay = begin_record_atomic_recovery(
self.session,
tenant_id="tenant-1",
operation_type="record.close",
idempotency_key="close-1",
request={"record_id": "record-1", "expected_revision": 1},
resource_type="record",
resource_id="record-1",
)
self.assertTrue(replay.replayed)
self.assertIsNone(replay.operation)
with self.assertRaisesRegex(RecordRecoveryError, "not started"):
begin_record_atomic_recovery(
self.session,
tenant_id="tenant-1",
operation_type="record.close",
idempotency_key="close-1",
request={"record_id": "record-1", "expected_revision": 9},
resource_type="record",
resource_id="record-1",
)
def test_unresolved_resource_blocks_another_runtime_effect(self) -> None:
started = begin_record_atomic_recovery(
self.session,
tenant_id="tenant-1",
operation_type="transfer.simulate",
idempotency_key="dispatch-1",
request={"record_id": "record-1", "package_id": "package-1"},
resource_type="record",
resource_id="record-1",
)
bind_process_runtime_identity(
RuntimeIdentity(
installation_id="test-installation",
node_id="other-records-test-node",
incarnation="22222222-2222-4222-8222-222222222222",
role="api",
software_version="test",
composition_hash="a" * 64,
)
)
with self.assertRaisesRegex(RecordRecoveryError, "Another runtime"):
begin_record_atomic_recovery(
self.session,
tenant_id="tenant-1",
operation_type="record.reopen",
idempotency_key="reopen-1",
request={"record_id": "record-1"},
resource_type="record",
resource_id="record-1",
)
bind_process_runtime_identity(
RuntimeIdentity(
installation_id="test-installation",
node_id="records-test-node",
incarnation="11111111-1111-4111-8111-111111111111",
role="api",
software_version="test",
composition_hash="a" * 64,
)
)
started.fail(summary="Test cleanup", error_type="TestInterruption")
def test_recovery_evidence_survives_database_restore(self) -> None:
started = begin_record_atomic_recovery(
self.session,
tenant_id="tenant-1",
operation_type="record.close",
idempotency_key="restore-close-1",
request={"record_id": "record-restore", "expected_revision": 1},
resource_type="record",
resource_id="record-restore",
)
started.commit_success(
self.session,
result={"record_id": "record-restore", "revision": 2},
resource_id="record-restore",
)
restored_path = Path(self.directory.name) / "restored.db"
with (
sqlite3.connect(self.database_path) as source,
sqlite3.connect(restored_path) as target,
):
source.backup(target)
restored_engine = create_engine(f"sqlite:///{restored_path}")
try:
with Session(restored_engine) as restored:
operation = restored.get(RecoveryOperation, started.operation_id)
self.assertIsNotNone(operation)
self.assertEqual(RecoveryStatus.SUCCEEDED.value, operation.status)
self.assertTrue(
verify_recovery_evidence_chain(restored, started.operation_id)
)
finally:
restored_engine.dispose()
if __name__ == "__main__":
unittest.main()
+185
View File
@@ -12,6 +12,12 @@ export type FilePlanNode = {
valid_from?: string | null;
valid_to?: string | null;
recorded_at: string;
closed_at?: string | null;
retention_started_at?: string | null;
retention_due_at?: string | null;
retention_rule: Record<string, unknown>;
appraisal_state?: string | null;
appraisal: Record<string, unknown>;
institutional_context: Record<string, unknown>;
};
@@ -99,11 +105,63 @@ export type RecordChronology = {
payload: Record<string, unknown>;
};
export type RecordHold = {
hold_id: string;
record_id: string;
revision: number;
status: string;
reason: string;
authority: string;
scope: Record<string, unknown>;
effective_from: string;
effective_to?: string | null;
released_at?: string | null;
policy_refs: string[];
recorded_at: string;
};
export type RecordDisposition = {
disposition_id: string;
record_id: string;
revision: number;
action: "retain" | "transfer" | "destroy" | "reclassify";
status: string;
reason: string;
subject_revision: number;
subject_sha256: string;
consequence_preview: Record<string, unknown>;
policy_refs: string[];
approval_request_id?: string | null;
proposed_by?: string | null;
reviewed_by?: string | null;
reviewed_at?: string | null;
recorded_at: string;
};
export type RecordTransferPackage = {
package_id: string;
record_id: string;
disposition_id: string;
revision: number;
record_revision: number;
provider_id: string;
profile: string;
status: string;
manifest_sha256: string;
receipt_sha256?: string | null;
external_reference?: string | null;
simulated: boolean;
recorded_at: string;
};
export type RecordDetail = {
record: RecordEntry;
volumes: RecordVolume[];
items: RecordItem[];
chronology: RecordChronology[];
holds: RecordHold[];
dispositions: RecordDisposition[];
transfer_packages: RecordTransferPackage[];
access_explanation: {
decision: string;
reason: string;
@@ -120,6 +178,19 @@ export type RecordSourceProvider = {
resource_types: string[];
};
export type RecordArchiveProvider = {
id: string;
label: string;
profiles: string[];
authority_modes: string[];
healthy: boolean;
checked_at: string;
last_success_at?: string | null;
freshness_seconds?: number | null;
limitations: string[];
simulated: boolean;
};
export function listRecords(
settings: ApiSettings,
options: {
@@ -154,6 +225,24 @@ export function getRecordSources(settings: ApiSettings, signal?: AbortSignal): P
return apiFetch(settings, "/api/v1/records/sources", { signal });
}
export function getRecordArchiveProviders(settings: ApiSettings, signal?: AbortSignal): Promise<{ providers: RecordArchiveProvider[] }> {
return apiFetch(settings, "/api/v1/records/archive-providers", { signal });
}
export function writeFilePlanNode(settings: ApiSettings, payload: Record<string, unknown>): Promise<FilePlanNode> {
return apiFetch(settings, "/api/v1/records/catalog/file-plan", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function writeRecordClass(settings: ApiSettings, payload: Record<string, unknown>): Promise<RecordClass> {
return apiFetch(settings, "/api/v1/records/catalog/classes", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function createRecord(settings: ApiSettings, payload: Record<string, unknown>): Promise<RecordEntry> {
return apiFetch(settings, "/api/v1/records", {
method: "POST",
@@ -182,3 +271,99 @@ export function fileRecordItem(
body: JSON.stringify(payload)
});
}
export function createRecordVolume(
settings: ApiSettings,
recordId: string,
payload: Record<string, unknown>
): Promise<RecordVolume> {
return recordMutation(settings, recordId, "volumes", payload);
}
export function closeRecord(settings: ApiSettings, recordId: string, payload: Record<string, unknown>): Promise<RecordEntry> {
return recordMutation(settings, recordId, "close", payload);
}
export function reopenRecord(settings: ApiSettings, recordId: string, payload: Record<string, unknown>): Promise<RecordEntry> {
return recordMutation(settings, recordId, "reopen", payload);
}
export function appraiseRecord(settings: ApiSettings, recordId: string, payload: Record<string, unknown>): Promise<RecordEntry> {
return recordMutation(settings, recordId, "appraise", payload);
}
export function applyRecordHold(settings: ApiSettings, recordId: string, payload: Record<string, unknown>): Promise<RecordHold> {
return recordMutation(settings, recordId, "holds", payload);
}
export function releaseRecordHold(
settings: ApiSettings,
recordId: string,
holdId: string,
payload: Record<string, unknown>
): Promise<RecordHold> {
return recordMutation(settings, recordId, `holds/${encodeURIComponent(holdId)}/release`, payload);
}
export function proposeRecordDisposition(
settings: ApiSettings,
recordId: string,
payload: Record<string, unknown>
): Promise<RecordDisposition> {
return recordMutation(settings, recordId, "dispositions", payload);
}
export function finalizeRecordDisposition(
settings: ApiSettings,
recordId: string,
dispositionId: string,
payload: Record<string, unknown>
): Promise<{ disposition: RecordDisposition; record: RecordEntry }> {
return recordMutation(settings, recordId, `dispositions/${encodeURIComponent(dispositionId)}/finalize`, payload);
}
export function withdrawRecordDisposition(
settings: ApiSettings,
recordId: string,
dispositionId: string,
payload: Record<string, unknown>
): Promise<RecordDisposition> {
return recordMutation(settings, recordId, `dispositions/${encodeURIComponent(dispositionId)}/withdraw`, payload);
}
export function prepareRecordTransfer(
settings: ApiSettings,
recordId: string,
payload: Record<string, unknown>
): Promise<RecordTransferPackage> {
return recordMutation(settings, recordId, "transfer-packages", payload);
}
export function dispatchRecordTransfer(
settings: ApiSettings,
recordId: string,
packageId: string,
payload: Record<string, unknown>
): Promise<RecordTransferPackage> {
return recordMutation(settings, recordId, `transfer-packages/${encodeURIComponent(packageId)}/dispatch`, payload);
}
export function getRecordRecoveryStatus(
settings: ApiSettings,
recordId: string,
signal?: AbortSignal
): Promise<Record<string, unknown>> {
return apiFetch(settings, `/api/v1/records/${encodeURIComponent(recordId)}/recovery`, { signal });
}
function recordMutation<T>(
settings: ApiSettings,
recordId: string,
path: string,
payload: Record<string, unknown>
): Promise<T> {
return apiFetch(settings, `/api/v1/records/${encodeURIComponent(recordId)}/${path}`, {
method: "POST",
body: JSON.stringify(payload)
});
}
@@ -0,0 +1,174 @@
import { useEffect, useState, type FormEvent } from "react";
import {
Button,
Dialog,
DismissibleAlert,
FormField,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
writeFilePlanNode,
writeRecordClass,
type RecordCatalog
} from "../../api/records";
export function RecordCatalogDialog({
open,
settings,
catalog,
onClose,
onSaved
}: {
open: boolean;
settings: PlatformRouteContext["settings"];
catalog: RecordCatalog;
onClose: () => void;
onSaved: () => void;
}) {
const [kind, setKind] = useState<"file-plan" | "class">("file-plan");
const [selectedId, setSelectedId] = useState("");
const [identifier, setIdentifier] = useState("");
const [code, setCode] = useState("");
const [label, setLabel] = useState("");
const [description, setDescription] = useState("");
const [parentId, setParentId] = useState("");
const [filePlanNodeId, setFilePlanNodeId] = useState("");
const [retentionDays, setRetentionDays] = useState("");
const [closureTrigger, setClosureTrigger] = useState("record_closed");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!open) return;
setKind("file-plan");
setSelectedId("");
resetFields();
}, [open]);
useEffect(() => {
if (!selectedId) {
resetFields();
return;
}
if (kind === "file-plan") {
const item = catalog.file_plan.find((candidate) => candidate.node_id === selectedId);
if (!item) return;
setIdentifier(item.node_id);
setCode(item.code);
setLabel(item.label);
setDescription(item.description ?? "");
setParentId(item.parent_node_id ?? "");
} else {
const item = catalog.classes.find((candidate) => candidate.class_id === selectedId);
if (!item) return;
setIdentifier(item.class_id);
setCode(item.key);
setLabel(item.label);
setDescription(item.description ?? "");
setFilePlanNodeId(item.file_plan_node_id);
setRetentionDays(item.retention_period_days == null ? "" : String(item.retention_period_days));
setClosureTrigger(item.closure_trigger ?? "record_closed");
}
}, [catalog.classes, catalog.file_plan, kind, selectedId]);
function resetFields() {
setIdentifier("");
setCode("");
setLabel("");
setDescription("");
setParentId("");
setFilePlanNodeId(catalog.file_plan[0]?.node_id ?? "");
setRetentionDays("");
setClosureTrigger("record_closed");
setError("");
}
async function submit(event: FormEvent) {
event.preventDefault();
const current = kind === "file-plan"
? catalog.file_plan.find((item) => item.node_id === selectedId)
: catalog.classes.find((item) => item.class_id === selectedId);
setSaving(true);
setError("");
try {
if (kind === "file-plan") {
await writeFilePlanNode(settings, {
node_id: identifier.trim(),
code: code.trim(),
label: label.trim(),
description: description.trim() || null,
parent_node_id: parentId || null,
active: true,
recorded_at: new Date().toISOString(),
expected_revision: current?.revision,
idempotency_key: randomId(),
institutional_context: {}
});
} else {
await writeRecordClass(settings, {
class_id: identifier.trim(),
file_plan_node_id: filePlanNodeId,
key: code.trim(),
label: label.trim(),
description: description.trim() || null,
metadata_requirements: [],
allowed_source_types: [],
retention_period_days: retentionDays === "" ? null : Number(retentionDays),
closure_trigger: closureTrigger,
access_mode: "tenant",
active: true,
recorded_at: new Date().toISOString(),
expected_revision: current?.revision,
idempotency_key: randomId(),
institutional_context: {}
});
}
onSaved();
} catch (reason) {
setError(reason instanceof Error && reason.message ? reason.message : "The Records catalog could not be saved.");
} finally {
setSaving(false);
}
}
const isValid = identifier.trim() && code.trim() && label.trim() && (kind === "file-plan" || filePlanNodeId);
const options = kind === "file-plan" ? catalog.file_plan : catalog.classes;
return (
<Dialog
open={open}
title="Administer Records catalog"
onClose={onClose}
closeDisabled={saving}
portal
className="records-dialog"
helpContextId="records.catalog.admin"
footer={<><Button type="button" variant="ghost" onClick={onClose} disabled={saving}>Cancel</Button><Button type="submit" form="records-catalog-form" variant="primary" disabledReason={saving ? "The catalog revision is being saved." : !isValid ? "Complete all required catalog fields." : undefined}>{saving ? "Saving" : "Save revision"}</Button></>}
>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<form id="records-catalog-form" className="records-dialog-form" onSubmit={submit}>
<FormField label="Catalog object">
<select value={kind} onChange={(event) => { setKind(event.target.value as typeof kind); setSelectedId(""); }}>
<option value="file-plan">File-plan node</option>
<option value="class">Record class</option>
</select>
</FormField>
<FormField label="Revision target">
<select value={selectedId} onChange={(event) => setSelectedId(event.target.value)}>
<option value="">New {kind === "file-plan" ? "file-plan node" : "record class"}</option>
{options.map((item) => <option key={kind === "file-plan" ? "node_id" in item ? item.node_id : "" : "class_id" in item ? item.class_id : ""} value={kind === "file-plan" ? "node_id" in item ? item.node_id : "" : "class_id" in item ? item.class_id : ""}>{item.label} · revision {item.revision}</option>)}
</select>
</FormField>
<FormField label={kind === "file-plan" ? "Node ID" : "Class ID"}><input value={identifier} onChange={(event) => setIdentifier(event.target.value)} disabled={Boolean(selectedId)} required /></FormField>
<FormField label={kind === "file-plan" ? "Code" : "Key"}><input value={code} onChange={(event) => setCode(event.target.value)} required /></FormField>
<FormField label="Label"><input value={label} onChange={(event) => setLabel(event.target.value)} required /></FormField>
{kind === "file-plan" ? <FormField label="Parent node"><select value={parentId} onChange={(event) => setParentId(event.target.value)}><option value="">Top level</option>{catalog.file_plan.filter((item) => item.node_id !== identifier).map((item) => <option key={item.node_id} value={item.node_id}>{item.code} · {item.label}</option>)}</select></FormField> : <><FormField label="File-plan node"><select value={filePlanNodeId} onChange={(event) => setFilePlanNodeId(event.target.value)} required>{catalog.file_plan.map((item) => <option key={item.node_id} value={item.node_id}>{item.code} · {item.label}</option>)}</select></FormField><FormField label="Retention period (days)"><input type="number" min="0" value={retentionDays} onChange={(event) => setRetentionDays(event.target.value)} /></FormField><FormField label="Retention trigger"><select value={closureTrigger} onChange={(event) => setClosureTrigger(event.target.value)}><option value="record_closed">Record closure</option><option value="calendar_month_end">End of closure month</option><option value="calendar_year_end">End of closure year</option><option value="explicit">Explicit date at closure</option></select></FormField></>}
<FormField label="Description"><textarea rows={4} value={description} onChange={(event) => setDescription(event.target.value)} /></FormField>
</form>
</Dialog>
);
}
function randomId() {
return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
@@ -0,0 +1,383 @@
import {
ArchiveRestore,
Ban,
Box,
CheckCheck,
CirclePause,
FolderPlus,
LockKeyhole,
Play,
RotateCcw
} from "lucide-react";
import { useEffect, useMemo, useState, type FormEvent } from "react";
import {
Button,
Dialog,
DismissibleAlert,
FormField,
StatusBadge,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
applyRecordHold,
appraiseRecord,
closeRecord,
createRecordVolume,
dispatchRecordTransfer,
finalizeRecordDisposition,
getRecordRecoveryStatus,
prepareRecordTransfer,
proposeRecordDisposition,
releaseRecordHold,
reopenRecord,
withdrawRecordDisposition,
type RecordArchiveProvider,
type RecordDetail,
type RecordDisposition,
type RecordTransferPackage
} from "../../api/records";
type ActionKind =
| "volume"
| "close"
| "reopen"
| "appraise"
| "hold"
| "release-hold"
| "disposition"
| "finalize"
| "withdraw"
| "prepare-transfer"
| "dispatch-transfer"
| "recovery";
export function RecordLifecyclePanel({
detail,
archiveProviders,
settings,
canWrite,
canAdmin,
onChanged
}: {
detail: RecordDetail;
archiveProviders: RecordArchiveProvider[];
settings: PlatformRouteContext["settings"];
canWrite: boolean;
canAdmin: boolean;
onChanged: () => void;
}) {
const [action, setAction] = useState<ActionKind | null>(null);
const [targetId, setTargetId] = useState("");
const [reason, setReason] = useState("");
const [purpose, setPurpose] = useState(detail.record.purpose);
const [authority, setAuthority] = useState("");
const [label, setLabel] = useState("");
const [outcome, setOutcome] = useState<"retain" | "transfer" | "destroy" | "reclassify">("retain");
const [providerId, setProviderId] = useState("");
const [profile, setProfile] = useState("");
const [retentionTriggerAt, setRetentionTriggerAt] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [recovery, setRecovery] = useState<Record<string, unknown> | null>(null);
const record = detail.record;
const currentDisposition = detail.dispositions.find((item) => item.status !== "withdrawn");
const currentPackage = detail.transfer_packages[0];
const activeHolds = detail.holds.filter((item) => item.status === "active");
const healthyProviders = archiveProviders.filter((item) => item.healthy);
const selectedProvider = healthyProviders.find((item) => item.id === providerId);
useEffect(() => setPurpose(record.purpose), [record.purpose]);
useEffect(() => {
if (selectedProvider?.profiles.includes(profile)) return;
setProfile(selectedProvider?.profiles[0] ?? "");
}, [profile, selectedProvider]);
const availableActions = useMemo(() => ({
canClose: record.state === "open",
canReopen: ["closed", "retention_running", "appraised"].includes(record.state) && !currentDisposition,
canAppraise: ["closed", "retention_running"].includes(record.state),
canPropose: record.state === "appraised" && !currentDisposition,
canFinalize: currentDisposition?.status === "review_pending",
canPrepare: currentDisposition?.status === "approved" && currentDisposition.action === "transfer" && !currentPackage,
canDispatch: currentPackage?.status === "prepared"
}), [currentDisposition, currentPackage, record.state]);
function openAction(kind: ActionKind, id = "") {
setAction(kind);
setTargetId(id);
setReason("");
setAuthority("");
setLabel("");
setOutcome((record.appraisal_state as typeof outcome | null) ?? "retain");
const provider = healthyProviders[0];
setProviderId(provider?.id ?? "");
setProfile(provider?.profiles[0] ?? "");
setRetentionTriggerAt("");
setRecovery(null);
setError("");
}
async function openRecovery() {
openAction("recovery");
setSaving(true);
try {
setRecovery(await getRecordRecoveryStatus(settings, record.record_id));
} catch (reason) {
setError(errorMessage(reason, "Recovery evidence could not be loaded."));
} finally {
setSaving(false);
}
}
async function submit(event: FormEvent) {
event.preventDefault();
if (!action || action === "recovery") return;
setSaving(true);
setError("");
const recordedAt = new Date().toISOString();
const idempotencyKey = randomId();
try {
if (action === "volume") {
await createRecordVolume(settings, record.record_id, {
label: label.trim(),
purpose: purpose.trim(),
recorded_at: recordedAt,
idempotency_key: idempotencyKey
});
} else if (action === "close") {
await closeRecord(settings, record.record_id, {
expected_revision: record.revision,
purpose: purpose.trim(),
reason: reason.trim(),
retention_trigger_at: retentionTriggerAt ? new Date(retentionTriggerAt).toISOString() : null,
restart_retention: false,
recorded_at: recordedAt,
idempotency_key: idempotencyKey
});
} else if (action === "reopen") {
await reopenRecord(settings, record.record_id, lifecyclePayload(record.revision, purpose, reason, recordedAt, idempotencyKey));
} else if (action === "appraise") {
await appraiseRecord(settings, record.record_id, {
...lifecyclePayload(record.revision, purpose, reason, recordedAt, idempotencyKey),
outcome,
policy_refs: [],
override_retention_not_due: canAdmin && record.retention_due_at != null && new Date(record.retention_due_at) > new Date()
});
} else if (action === "hold") {
await applyRecordHold(settings, record.record_id, {
expected_record_revision: record.revision,
purpose: purpose.trim(),
reason: reason.trim(),
authority: authority.trim(),
scope: {},
policy_refs: [],
institutional_context: record.institutional_context,
recorded_at: recordedAt,
idempotency_key: idempotencyKey
});
} else if (action === "release-hold") {
const hold = detail.holds.find((item) => item.hold_id === targetId);
if (!hold) throw new Error("The selected hold is no longer available.");
await releaseRecordHold(settings, record.record_id, hold.hold_id, {
expected_hold_revision: hold.revision,
purpose: purpose.trim(),
reason: reason.trim(),
recorded_at: recordedAt,
idempotency_key: idempotencyKey
});
} else if (action === "disposition") {
await proposeRecordDisposition(settings, record.record_id, {
expected_record_revision: record.revision,
action: outcome,
purpose: purpose.trim(),
reason: reason.trim(),
policy_refs: [],
institutional_context: record.institutional_context,
recorded_at: recordedAt,
idempotency_key: idempotencyKey
});
} else if (action === "finalize") {
if (!currentDisposition) throw new Error("The disposition is no longer available.");
await finalizeRecordDisposition(settings, record.record_id, currentDisposition.disposition_id, {
expected_disposition_revision: currentDisposition.revision,
purpose: purpose.trim(),
recorded_at: recordedAt,
idempotency_key: idempotencyKey
});
} else if (action === "withdraw") {
if (!currentDisposition) throw new Error("The disposition is no longer available.");
await withdrawRecordDisposition(settings, record.record_id, currentDisposition.disposition_id, {
expected_disposition_revision: currentDisposition.revision,
purpose: purpose.trim(),
reason: reason.trim(),
recorded_at: recordedAt,
idempotency_key: idempotencyKey
});
} else if (action === "prepare-transfer") {
if (!currentDisposition) throw new Error("The disposition is no longer available.");
await prepareRecordTransfer(settings, record.record_id, {
disposition_id: currentDisposition.disposition_id,
expected_record_revision: record.revision,
provider_id: providerId,
profile,
purpose: purpose.trim(),
recorded_at: recordedAt,
idempotency_key: idempotencyKey
});
} else if (action === "dispatch-transfer") {
if (!currentPackage) throw new Error("The transfer package is no longer available.");
await dispatchRecordTransfer(settings, record.record_id, currentPackage.package_id, {
expected_package_revision: currentPackage.revision,
purpose: purpose.trim(),
recorded_at: recordedAt,
idempotency_key: idempotencyKey
});
}
setAction(null);
onChanged();
} catch (reason) {
setError(errorMessage(reason, "The Records lifecycle action failed."));
} finally {
setSaving(false);
}
}
return (
<>
<section className="records-detail-section" data-help-context-id="records.lifecycle">
<div className="records-section-heading">
<h2>Lifecycle</h2>
<StatusBadge status={activeHolds.length > 0 ? "danger" : "active"} label={activeHolds.length > 0 ? `${activeHolds.length} active hold(s)` : humanize(record.state)} />
</div>
<div className="records-lifecycle-actions">
<Button type="button" variant="ghost" onClick={() => openAction("volume")} disabledReason={!canWrite ? "Your account may not create record volumes." : undefined}>
<FolderPlus size={16} aria-hidden="true" /> Add volume
</Button>
{availableActions.canClose && <Button type="button" variant="ghost" onClick={() => openAction("close")}><CirclePause size={16} aria-hidden="true" /> Close record</Button>}
{availableActions.canReopen && <Button type="button" variant="ghost" onClick={() => openAction("reopen")}><RotateCcw size={16} aria-hidden="true" /> Reopen</Button>}
{availableActions.canAppraise && <Button type="button" variant="ghost" onClick={() => openAction("appraise")}><CheckCheck size={16} aria-hidden="true" /> Appraise</Button>}
<Button type="button" variant="ghost" onClick={() => openAction("hold")} disabledReason={!canWrite ? "Your account may not apply a hold." : undefined}><LockKeyhole size={16} aria-hidden="true" /> Apply hold</Button>
{availableActions.canPropose && <Button type="button" variant="primary" onClick={() => openAction("disposition")} disabledReason={activeHolds.length ? "Release all active holds first." : undefined}><ArchiveRestore size={16} aria-hidden="true" /> Propose disposition</Button>}
{availableActions.canFinalize && <Button type="button" variant="primary" onClick={() => openAction("finalize")} disabledReason={activeHolds.length ? "Release all active holds first." : undefined}><CheckCheck size={16} aria-hidden="true" /> Finalize approved disposition</Button>}
{currentDisposition && ["review_pending", "review_unavailable"].includes(currentDisposition.status) && <Button type="button" variant="ghost" onClick={() => openAction("withdraw")}><Ban size={16} aria-hidden="true" /> Withdraw proposal</Button>}
{availableActions.canPrepare && <Button type="button" variant="primary" onClick={() => openAction("prepare-transfer")} disabledReason={healthyProviders.length === 0 ? "No healthy archive profile is available." : activeHolds.length ? "Release all active holds first." : undefined}><Box size={16} aria-hidden="true" /> Prepare transfer</Button>}
{availableActions.canDispatch && <Button type="button" variant="primary" onClick={() => openAction("dispatch-transfer")} disabledReason={!currentPackage?.simulated ? "Real archive dispatch needs a target-specific recovery profile." : activeHolds.length ? "Release all active holds first." : undefined}><Play size={16} aria-hidden="true" /> Run simulation</Button>}
{canAdmin && <Button type="button" variant="ghost" onClick={openRecovery}><ArchiveRestore size={16} aria-hidden="true" /> Recovery evidence</Button>}
</div>
<LifecycleEvidence detail={detail} onRelease={(holdId) => openAction("release-hold", holdId)} canWrite={canWrite} />
</section>
<Dialog
open={action != null}
title={dialogTitle(action)}
onClose={() => !saving && setAction(null)}
closeDisabled={saving}
portal
className="records-dialog"
helpContextId={`records.lifecycle.${action ?? "action"}`}
footer={action === "recovery" ? <Button type="button" variant="primary" onClick={() => setAction(null)}>Close</Button> : (
<>
<Button type="button" variant="ghost" onClick={() => setAction(null)} disabled={saving}>Cancel</Button>
<Button type="submit" form="records-lifecycle-form" variant="primary" disabledReason={submitDisabledReason(action, { reason, purpose, authority, label, providerId, profile, saving })}>{saving ? "Saving" : actionLabel(action)}</Button>
</>
)}
>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{action === "recovery" ? <RecoveryResult value={recovery} loading={saving} /> : (
<form id="records-lifecycle-form" className="records-dialog-form" onSubmit={submit}>
{action === "volume" && <FormField label="Volume label" helpContextId="records.field.volume-label"><input value={label} onChange={(event) => setLabel(event.target.value)} required /></FormField>}
{["appraise", "disposition"].includes(action ?? "") && <FormField label="Disposition" helpContextId="records.field.disposition"><select value={outcome} onChange={(event) => setOutcome(event.target.value as typeof outcome)}><option value="retain">Retain</option><option value="transfer">Transfer</option><option value="destroy">Destroy</option><option value="reclassify">Reclassify</option></select></FormField>}
{action === "hold" && <FormField label="Authority" helpContextId="records.field.hold-authority"><input value={authority} onChange={(event) => setAuthority(event.target.value)} required /></FormField>}
{action === "close" && <FormField label="Explicit retention trigger" helpContextId="records.field.retention-trigger"><input type="datetime-local" value={retentionTriggerAt} onChange={(event) => setRetentionTriggerAt(event.target.value)} /></FormField>}
{action === "prepare-transfer" && <><FormField label="Archive provider" helpContextId="records.field.archive-provider"><select value={providerId} onChange={(event) => setProviderId(event.target.value)} required>{healthyProviders.map((item) => <option key={item.id} value={item.id}>{item.label}{item.simulated ? " (simulation)" : ""}</option>)}</select></FormField><FormField label="Transfer profile" helpContextId="records.field.archive-profile"><select value={profile} onChange={(event) => setProfile(event.target.value)} required>{selectedProvider?.profiles.map((item) => <option key={item} value={item}>{item}</option>)}</select></FormField></>}
{action === "dispatch-transfer" && <DismissibleAlert tone="warning" dismissible={false} compact>This validates transfer handling only. Archival custody is not transferred.</DismissibleAlert>}
{action === "finalize" && currentDisposition?.approval_request_id && <DismissibleAlert tone="info" dismissible={false} compact>Approval request <a href="/approvals">{currentDisposition.approval_request_id}</a> must be approved by another account.</DismissibleAlert>}
{requiresReason(action) && <FormField label="Reason" helpContextId="records.field.lifecycle-reason"><textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={4} required /></FormField>}
<FormField label="Purpose"><input value={purpose} onChange={(event) => setPurpose(event.target.value)} required /></FormField>
</form>
)}
</Dialog>
</>
);
}
function LifecycleEvidence({ detail, onRelease, canWrite }: { detail: RecordDetail; onRelease: (holdId: string) => void; canWrite: boolean }) {
const record = detail.record;
return (
<div className="records-lifecycle-evidence">
<dl>
<div><dt>Closed</dt><dd>{formatDateTime(record.closed_at)}</dd></div>
<div><dt>Retention starts</dt><dd>{formatDateTime(record.retention_started_at)}</dd></div>
<div><dt>Retention due</dt><dd>{formatDateTime(record.retention_due_at)}</dd></div>
<div><dt>Appraisal</dt><dd>{record.appraisal_state ? humanize(record.appraisal_state) : "Not appraised"}</dd></div>
</dl>
{detail.volumes.length > 0 && <div className="records-evidence-list"><strong>Volumes</strong>{detail.volumes.map((volume) => <div key={volume.volume_id}><span>{volume.sequence}. {volume.label}</span><StatusBadge status={volume.state === "open" ? "active" : "neutral"} label={humanize(volume.state)} /></div>)}</div>}
{detail.holds.length > 0 && <div className="records-evidence-list"><strong>Holds</strong>{detail.holds.map((hold) => <div key={hold.hold_id}><span><b>{hold.authority}</b> · {hold.reason}</span><span className="records-evidence-actions"><StatusBadge status={hold.status === "active" ? "danger" : "neutral"} label={humanize(hold.status)} />{hold.status === "active" && <Button type="button" variant="ghost" iconOnly aria-label="Release hold" title="Release hold" onClick={() => onRelease(hold.hold_id)} disabledReason={!canWrite ? "Your account may not release holds." : undefined}><Ban size={15} aria-hidden="true" /></Button>}</span></div>)}</div>}
{detail.dispositions.map((item) => <DispositionEvidence key={item.disposition_id} item={item} />)}
{detail.transfer_packages.map((item) => <TransferEvidence key={item.package_id} item={item} />)}
</div>
);
}
function DispositionEvidence({ item }: { item: RecordDisposition }) {
return <div className="records-evidence-list"><strong>Disposition</strong><div><span><b>{humanize(item.action)}</b> · {item.reason}</span><StatusBadge status={item.status === "approved" ? "active" : item.status === "review_unavailable" ? "danger" : "warning"} label={humanize(item.status)} /></div>{item.approval_request_id && <small>Approval: {item.approval_request_id}</small>}</div>;
}
function TransferEvidence({ item }: { item: RecordTransferPackage }) {
return <div className="records-evidence-list"><strong>Transfer package</strong><div><span><b>{item.profile}</b> · {item.manifest_sha256.slice(0, 12)}</span><StatusBadge status={item.status === "simulated_accepted" ? "warning" : item.status === "accepted" ? "active" : "neutral"} label={humanize(item.status)} /></div>{item.simulated && <small>Simulation only; no archival custody.</small>}</div>;
}
function RecoveryResult({ value, loading }: { value: Record<string, unknown> | null; loading: boolean }) {
if (loading) return <p>Loading recovery evidence...</p>;
if (!value) return null;
const healthy = Boolean(value.healthy);
return <div className="records-recovery-result"><DismissibleAlert tone={healthy ? "success" : "danger"} dismissible={false}>{healthy ? "Recovery evidence is complete." : `${String(value.failure_count)} recovery check(s) require attention.`}</DismissibleAlert><dl><div><dt>Evidence digest</dt><dd>{String(value.evidence_sha256)}</dd></div><div><dt>Source checks</dt><dd>{Array.isArray(value.source_checks) ? value.source_checks.length : 0}</dd></div><div><dt>Recovery operations</dt><dd>{Array.isArray(value.recovery_operations) ? value.recovery_operations.length : 0}</dd></div></dl></div>;
}
function lifecyclePayload(expectedRevision: number, purpose: string, reason: string, recordedAt: string, idempotencyKey: string) {
return { expected_revision: expectedRevision, purpose: purpose.trim(), reason: reason.trim(), recorded_at: recordedAt, idempotency_key: idempotencyKey };
}
function requiresReason(action: ActionKind | null) {
return action != null && ["close", "reopen", "appraise", "hold", "release-hold", "disposition", "withdraw"].includes(action);
}
function dialogTitle(action: ActionKind | null) {
if (action === "close") return "Close record";
return action ? humanize(action) : "Records action";
}
function actionLabel(action: ActionKind | null) {
if (action === "dispatch-transfer") return "Run simulation";
if (action === "finalize") return "Finalize";
if (action === "close") return "Close record";
return action ? humanize(action) : "Save";
}
function submitDisabledReason(action: ActionKind | null, values: { reason: string; purpose: string; authority: string; label: string; providerId: string; profile: string; saving: boolean }) {
if (values.saving) return "The lifecycle action is being saved.";
if (!values.purpose.trim()) return "Enter the purpose for this action.";
if (requiresReason(action) && !values.reason.trim()) return "Enter a reason for this action.";
if (action === "hold" && !values.authority.trim()) return "Enter the hold authority.";
if (action === "volume" && !values.label.trim()) return "Enter a volume label.";
if (action === "prepare-transfer" && (!values.providerId || !values.profile)) return "Select an available archive profile.";
return undefined;
}
function formatDateTime(value?: string | null) {
return value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "Not set";
}
function humanize(value: string) {
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function randomId() {
return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function errorMessage(reason: unknown, fallback: string) {
return reason instanceof Error && reason.message ? reason.message : fallback;
}
+71 -6
View File
@@ -4,7 +4,8 @@ import {
Pencil,
Plus,
RefreshCw,
Search
Search,
Settings2
} from "lucide-react";
import {
useCallback,
@@ -33,17 +34,21 @@ import {
createRecord,
fileRecordItem,
getRecord,
getRecordArchiveProviders,
getRecordCatalog,
getRecordSources,
listRecords,
updateRecord,
type FilePlanNode,
type RecordArchiveProvider,
type RecordCatalog,
type RecordClass,
type RecordDetail,
type RecordEntry,
type RecordSourceProvider
} from "../../api/records";
import { RecordCatalogDialog } from "./RecordCatalogDialog";
import { RecordLifecyclePanel } from "./RecordLifecyclePanel";
import {
RECORDS_DOCUMENTATION,
RECORDS_FIELD_DOCUMENTATION
@@ -68,6 +73,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
);
const [detail, setDetail] = useState<RecordDetail | null>(null);
const [sources, setSources] = useState<RecordSourceProvider[]>([]);
const [archiveProviders, setArchiveProviders] = useState<RecordArchiveProvider[]>([]);
const [loading, setLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
const [error, setError] = useState("");
@@ -76,8 +82,10 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [fileOpen, setFileOpen] = useState(false);
const [catalogOpen, setCatalogOpen] = useState(false);
const canWrite = auth.scopes.includes("records:workspace:write") ||
auth.scopes.includes("records:workspace:admin");
const canAdmin = auth.scopes.includes("records:workspace:admin");
const reload = useCallback(() => setReloadKey((value) => value + 1), []);
@@ -88,11 +96,15 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
getRecordCatalog(settings, controller.signal),
canWrite
? getRecordSources(settings, controller.signal)
: Promise.resolve({ providers: [] as RecordSourceProvider[] })
: Promise.resolve({ providers: [] as RecordSourceProvider[] }),
canWrite
? getRecordArchiveProviders(settings, controller.signal)
: Promise.resolve({ providers: [] as RecordArchiveProvider[] })
]).
then(([nextCatalog, sourceResult]) => {
then(([nextCatalog, sourceResult, archiveResult]) => {
setCatalog(nextCatalog);
setSources(sourceResult.providers);
setArchiveProviders(archiveResult.providers);
}).
catch((reason) => {
if ((reason as Error).name !== "AbortError") {
@@ -227,6 +239,12 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
<RefreshCw size={16} aria-hidden="true" />
Refresh
</Button>
{canAdmin && (
<Button type="button" variant="ghost" onClick={() => setCatalogOpen(true)} helpContextId="records.catalog.admin">
<Settings2 size={16} aria-hidden="true" />
Catalog
</Button>
)}
<form className="records-search" onSubmit={submitSearch}>
<Search size={17} aria-hidden="true" />
<input
@@ -335,10 +353,14 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
<RecordDetailPanel
detail={detail}
recordClass={classesById.get(detail.record.class_id)}
settings={settings}
archiveProviders={archiveProviders}
canWrite={canWrite}
canAdmin={canAdmin}
hasSources={sources.length > 0}
onEdit={() => setEditOpen(true)}
onFile={() => setFileOpen(true)}
onChanged={reload}
/>
)}
</section>
@@ -375,6 +397,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
open={fileOpen}
settings={settings}
record={detail?.record ?? null}
volumes={detail?.volumes ?? []}
providers={sources}
onClose={() => setFileOpen(false)}
onSaved={() => {
@@ -382,6 +405,16 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
reload();
}}
/>
<RecordCatalogDialog
open={catalogOpen}
settings={settings}
catalog={catalog}
onClose={() => setCatalogOpen(false)}
onSaved={() => {
setCatalogOpen(false);
reload();
}}
/>
</main>
);
}
@@ -389,17 +422,25 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
function RecordDetailPanel({
detail,
recordClass,
settings,
archiveProviders,
canWrite,
canAdmin,
hasSources,
onEdit,
onFile
onFile,
onChanged
}: {
detail: RecordDetail;
recordClass?: RecordClass;
settings: PlatformRouteContext["settings"];
archiveProviders: RecordArchiveProvider[];
canWrite: boolean;
canAdmin: boolean;
hasSources: boolean;
onEdit: () => void;
onFile: () => void;
onChanged: () => void;
}) {
const record = detail.record;
return (
@@ -414,7 +455,7 @@ function RecordDetailPanel({
<Pencil size={16} aria-hidden="true" />
Edit
</Button>
<Button type="button" variant="primary" onClick={onFile} disabledReason={!canWrite ? "Your account may not file record items." : !hasSources ? "No enabled source module provides exact record references." : undefined} helpContextId="records.action.file">
<Button type="button" variant="primary" onClick={onFile} disabledReason={!canWrite ? "Your account may not file record items." : record.state !== "open" ? "Only open records accept new items." : !hasSources ? "No enabled source module provides exact record references." : undefined} helpContextId="records.action.file">
<FilePlus2 size={16} aria-hidden="true" />
File item
</Button>
@@ -434,6 +475,15 @@ function RecordDetailPanel({
{record.description && <p className="records-description">{record.description}</p>}
<RecordLifecyclePanel
detail={detail}
archiveProviders={archiveProviders}
settings={settings}
canWrite={canWrite}
canAdmin={canAdmin}
onChanged={onChanged}
/>
<section className="records-detail-section" data-help-context-id="records.record-items">
<div className="records-section-heading">
<h2>Contents</h2>
@@ -549,7 +599,7 @@ function RecordDialog({
file_plan_node_id: selectedClass.file_plan_node_id,
title: title.trim(),
description: description.trim() || null,
state,
...(mode === "create" || record?.state === "planned" || record?.state === "open" ? { state } : {}),
classification: classification.trim() || null,
purpose: purpose.trim(),
recorded_at: new Date().toISOString(),
@@ -601,10 +651,14 @@ function RecordDialog({
<input value={recordNumber} onChange={(event) => setRecordNumber(event.target.value)} disabled={mode === "edit"} required />
</FormField>
<FormField label="State" helpContextId="records.field.state">
{mode === "edit" && record && !["planned", "open"].includes(record.state) ? (
<input value={humanize(record.state)} disabled />
) : (
<select value={state} onChange={(event) => setState(event.target.value as "planned" | "open")}>
<option value="planned">Planned</option>
<option value="open">Open</option>
</select>
)}
</FormField>
<FormField label="Title" helpContextId="records.field.title">
<input value={title} onChange={(event) => setTitle(event.target.value)} required />
@@ -635,6 +689,7 @@ function FilingDialog({
open,
settings,
record,
volumes,
providers,
onClose,
onSaved
@@ -642,6 +697,7 @@ function FilingDialog({
open: boolean;
settings: PlatformRouteContext["settings"];
record: RecordEntry | null;
volumes: RecordDetail["volumes"];
providers: RecordSourceProvider[];
onClose: () => void;
onSaved: () => void;
@@ -650,6 +706,7 @@ function FilingDialog({
const [resourceType, setResourceType] = useState("");
const [resourceId, setResourceId] = useState("");
const [sourceRevision, setSourceRevision] = useState("");
const [volumeId, setVolumeId] = useState("");
const [purpose, setPurpose] = useState("");
const [filingReason, setFilingReason] = useState("");
const [saving, setSaving] = useState(false);
@@ -663,6 +720,7 @@ function FilingDialog({
setResourceType(first?.resource_types[0] ?? "");
setResourceId("");
setSourceRevision("");
setVolumeId("");
setPurpose(record?.purpose ?? "");
setFilingReason("");
setError("");
@@ -690,6 +748,7 @@ function FilingDialog({
purpose: purpose.trim(),
filing_reason: filingReason.trim(),
relationship: "contains",
volume_id: volumeId || null,
idempotency_key: randomId(),
institutional_context: record.institutional_context,
metadata: {}
@@ -741,6 +800,12 @@ function FilingDialog({
<FormField label="Exact source revision" documentation={RECORDS_FIELD_DOCUMENTATION.sourceRevision}>
<input value={sourceRevision} onChange={(event) => setSourceRevision(event.target.value)} required />
</FormField>
<FormField label="Record volume" helpContextId="records.field.volume">
<select value={volumeId} onChange={(event) => setVolumeId(event.target.value)}>
<option value="">No volume</option>
{volumes.map((item) => <option key={item.volume_id} value={item.volume_id}>{item.sequence}. {item.label}</option>)}
</select>
</FormField>
<FormField label="Purpose" documentation={RECORDS_FIELD_DOCUMENTATION.purpose}>
<input value={purpose} onChange={(event) => setPurpose(event.target.value)} required />
</FormField>
+148 -2
View File
@@ -76,7 +76,80 @@ const en = {
"Complete all required record fields.": "Complete all required record fields.",
"The item is being filed.": "The item is being filed.",
"Complete the exact source and filing reason.": "Complete the exact source and filing reason.",
"The source module verifies your current access and resolves this exact revision before Records stores the reference.": "The source module verifies your current access and resolves this exact revision before Records stores the reference."
"The source module verifies your current access and resolves this exact revision before Records stores the reference.": "The source module verifies your current access and resolves this exact revision before Records stores the reference.",
"Catalog": "Catalog",
"Administer Records catalog": "Administer Records catalog",
"Catalog object": "Catalog object",
"File-plan node": "File-plan node",
"Revision target": "Revision target",
"Node ID": "Node ID",
"Class ID": "Class ID",
"Code": "Code",
"Key": "Key",
"Label": "Label",
"Parent node": "Parent node",
"Top level": "Top level",
"Retention period (days)": "Retention period (days)",
"Retention trigger": "Retention trigger",
"Record closure": "Record closure",
"End of closure month": "End of closure month",
"End of closure year": "End of closure year",
"Explicit date at closure": "Explicit date at closure",
"Save revision": "Save revision",
"Lifecycle": "Lifecycle",
"Add volume": "Add volume",
"Close": "Close",
"Close record": "Close record",
"Reopen": "Reopen",
"Appraise": "Appraise",
"Apply hold": "Apply hold",
"Propose disposition": "Propose disposition",
"Finalize approved disposition": "Finalize approved disposition",
"Withdraw proposal": "Withdraw proposal",
"Prepare transfer": "Prepare transfer",
"Run simulation": "Run simulation",
"Recovery evidence": "Recovery evidence",
"Closed": "Closed",
"Retention starts": "Retention starts",
"Retention due": "Retention due",
"Appraisal": "Appraisal",
"Not appraised": "Not appraised",
"Volumes": "Volumes",
"Holds": "Holds",
"Disposition": "Disposition",
"Transfer package": "Transfer package",
"Simulation only; no archival custody.": "Simulation only; no archival custody.",
"Volume label": "Volume label",
"Authority": "Authority",
"Explicit retention trigger": "Explicit retention trigger",
"Archive provider": "Archive provider",
"Transfer profile": "Transfer profile",
"Reason": "Reason",
"Retain": "Retain",
"Transfer": "Transfer",
"Destroy": "Destroy",
"Reclassify": "Reclassify",
"Record volume": "Record volume",
"No volume": "No volume",
"This validates transfer handling only. Archival custody is not transferred.": "This validates transfer handling only. Archival custody is not transferred.",
"Recovery evidence is complete.": "Recovery evidence is complete.",
"Evidence digest": "Evidence digest",
"Source checks": "Source checks",
"Recovery operations": "Recovery operations",
"Loading recovery evidence...": "Loading recovery evidence...",
"Only open records accept new items.": "Only open records accept new items.",
"Your account may not create record volumes.": "Your account may not create record volumes.",
"Your account may not apply a hold.": "Your account may not apply a hold.",
"Your account may not release holds.": "Your account may not release holds.",
"Release all active holds first.": "Release all active holds first.",
"No healthy archive profile is available.": "No healthy archive profile is available.",
"Real archive dispatch needs a target-specific recovery profile.": "Real archive dispatch needs a target-specific recovery profile.",
"The lifecycle action is being saved.": "The lifecycle action is being saved.",
"Enter the purpose for this action.": "Enter the purpose for this action.",
"Enter a reason for this action.": "Enter a reason for this action.",
"Enter the hold authority.": "Enter the hold authority.",
"Enter a volume label.": "Enter a volume label.",
"Select an available archive profile.": "Select an available archive profile."
} as const;
const de: Record<keyof typeof en, string> = {
@@ -154,7 +227,80 @@ const de: Record<keyof typeof en, string> = {
"Complete all required record fields.": "Füllen Sie alle erforderlichen Aktenfelder aus.",
"The item is being filed.": "Das Objekt wird veraktet.",
"Complete the exact source and filing reason.": "Vervollständigen Sie die exakte Quelle und die Veraktungsbegründung.",
"The source module verifies your current access and resolves this exact revision before Records stores the reference.": "Das Quellmodul prüft Ihre aktuelle Berechtigung und löst diese exakte Revision auf, bevor Records die Referenz speichert."
"The source module verifies your current access and resolves this exact revision before Records stores the reference.": "Das Quellmodul prüft Ihre aktuelle Berechtigung und löst diese exakte Revision auf, bevor Records die Referenz speichert.",
"Catalog": "Katalog",
"Administer Records catalog": "Aktenkatalog verwalten",
"Catalog object": "Katalogobjekt",
"File-plan node": "Aktenplanposition",
"Revision target": "Revisionsziel",
"Node ID": "Positions-ID",
"Class ID": "Klassen-ID",
"Code": "Kennzeichen",
"Key": "Schlüssel",
"Label": "Bezeichnung",
"Parent node": "Übergeordnete Position",
"Top level": "Oberste Ebene",
"Retention period (days)": "Aufbewahrungsfrist (Tage)",
"Retention trigger": "Beginn der Aufbewahrung",
"Record closure": "Aktenabschluss",
"End of closure month": "Ende des Abschlussmonats",
"End of closure year": "Ende des Abschlussjahres",
"Explicit date at closure": "Explizites Datum beim Abschluss",
"Save revision": "Revision speichern",
"Lifecycle": "Lebenszyklus",
"Add volume": "Band anlegen",
"Close": "Schließen",
"Close record": "Akte abschließen",
"Reopen": "Wiedereröffnen",
"Appraise": "Bewerten",
"Apply hold": "Sperre setzen",
"Propose disposition": "Aussonderung vorschlagen",
"Finalize approved disposition": "Freigegebene Aussonderung abschließen",
"Withdraw proposal": "Vorschlag zurückziehen",
"Prepare transfer": "Übergabe vorbereiten",
"Run simulation": "Simulation ausführen",
"Recovery evidence": "Wiederherstellungsnachweis",
"Closed": "Abgeschlossen",
"Retention starts": "Aufbewahrung beginnt",
"Retention due": "Aufbewahrung endet",
"Appraisal": "Bewertung",
"Not appraised": "Nicht bewertet",
"Volumes": "Bände",
"Holds": "Sperren",
"Disposition": "Aussonderung",
"Transfer package": "Übergabepaket",
"Simulation only; no archival custody.": "Nur Simulation; keine Archivverwahrung.",
"Volume label": "Bandbezeichnung",
"Authority": "Anordnende Stelle",
"Explicit retention trigger": "Expliziter Aufbewahrungsbeginn",
"Archive provider": "Archivanbieter",
"Transfer profile": "Übergabeprofil",
"Reason": "Begründung",
"Retain": "Aufbewahren",
"Transfer": "Übergeben",
"Destroy": "Vernichten",
"Reclassify": "Neu klassifizieren",
"Record volume": "Aktenband",
"No volume": "Kein Band",
"This validates transfer handling only. Archival custody is not transferred.": "Dies prüft nur die Übergabeverarbeitung. Eine Archivverwahrung wird nicht übertragen.",
"Recovery evidence is complete.": "Der Wiederherstellungsnachweis ist vollständig.",
"Evidence digest": "Nachweisprüfsumme",
"Source checks": "Quellprüfungen",
"Recovery operations": "Wiederherstellungsvorgänge",
"Loading recovery evidence...": "Wiederherstellungsnachweis wird geladen...",
"Only open records accept new items.": "Nur offene Akten nehmen neue Objekte auf.",
"Your account may not create record volumes.": "Ihr Konto darf keine Aktenbände anlegen.",
"Your account may not apply a hold.": "Ihr Konto darf keine Sperre setzen.",
"Your account may not release holds.": "Ihr Konto darf keine Sperre aufheben.",
"Release all active holds first.": "Heben Sie zuerst alle aktiven Sperren auf.",
"No healthy archive profile is available.": "Es ist kein verfügbares Archivprofil betriebsbereit.",
"Real archive dispatch needs a target-specific recovery profile.": "Eine echte Archivübergabe benötigt ein zielspezifisches Wiederherstellungsprofil.",
"The lifecycle action is being saved.": "Die Lebenszyklusaktion wird gespeichert.",
"Enter the purpose for this action.": "Geben Sie den Zweck dieser Aktion an.",
"Enter a reason for this action.": "Geben Sie eine Begründung für diese Aktion an.",
"Enter the hold authority.": "Geben Sie die anordnende Stelle der Sperre an.",
"Enter a volume label.": "Geben Sie eine Bandbezeichnung an.",
"Select an available archive profile.": "Wählen Sie ein verfügbares Archivprofil."
};
export const generatedTranslations: PlatformTranslations = { en, de };
+1
View File
@@ -20,6 +20,7 @@ export const recordsModule: PlatformWebModule = {
"reporting",
"dms",
"policy",
"approvals",
"audit",
"search"
],
+106
View File
@@ -290,6 +290,106 @@
line-height: 1.55;
}
.records-lifecycle-actions {
display: flex;
flex-wrap: wrap;
gap: 7px;
padding: 10px 0;
border-bottom: var(--border-line);
}
.records-lifecycle-evidence {
display: grid;
gap: 12px;
padding-top: 12px;
}
.records-lifecycle-evidence > dl,
.records-recovery-result dl {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
margin: 0;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--line);
}
.records-lifecycle-evidence > dl > div,
.records-recovery-result dl > div {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
padding: 9px 10px;
background: var(--surface);
}
.records-lifecycle-evidence dt,
.records-recovery-result dt {
color: var(--muted);
font-size: 12px;
}
.records-lifecycle-evidence dd,
.records-recovery-result dd {
overflow-wrap: anywhere;
margin: 0;
font-weight: 700;
}
.records-evidence-list {
display: grid;
gap: 1px;
border-bottom: var(--border-line);
}
.records-evidence-list > strong {
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
}
.records-evidence-list > div {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 38px;
border-top: var(--border-line);
padding: 5px 0;
}
.records-evidence-list > div > span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.records-evidence-list small {
color: var(--muted);
padding-bottom: 7px;
}
.records-evidence-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 5px;
}
.records-recovery-result {
display: grid;
gap: 12px;
}
.records-recovery-result dl {
grid-template-columns: minmax(0, 2fr) repeat(2, minmax(0, 1fr));
}
.records-detail-section {
margin-top: 24px;
}
@@ -457,6 +557,10 @@
.records-facts {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.records-lifecycle-evidence > dl {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
@@ -503,6 +607,8 @@
}
.records-facts,
.records-lifecycle-evidence > dl,
.records-recovery-result dl,
.records-access-explanation dl,
.records-dialog-form {
grid-template-columns: 1fr;