feat(records): govern lifecycle recovery and transfer evidence
This commit is contained in:
+38
-13
@@ -17,8 +17,8 @@ Implementation is tracked in
|
|||||||
|
|
||||||
## Implementation Status
|
## Implementation Status
|
||||||
|
|
||||||
The native foundation is implemented through the work packages tracked by
|
The native foundation and governed lifecycle are implemented through the work
|
||||||
Records #2-#4:
|
packages tracked by Records #2-#6:
|
||||||
|
|
||||||
- versioned file plans and record classes;
|
- versioned file plans and record classes;
|
||||||
- stable records, immutable revisions, volumes, exact record items, and
|
- stable records, immutable revisions, volumes, exact record items, and
|
||||||
@@ -29,13 +29,24 @@ Records #2-#4:
|
|||||||
temporal status, create/edit, and filing actions;
|
temporal status, create/edit, and filing actions;
|
||||||
- a provider-neutral Core filing contract with exact Files-version and
|
- a provider-neutral Core filing contract with exact Files-version and
|
||||||
Cases-revision providers.
|
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:
|
The remaining boundary is intentionally visible rather than implied: Records
|
||||||
Records #5 owns closure, retention, holds, appraisal, and disposition; #6 owns
|
#7 requires selection and target testing of an archive/xdomea endpoint and
|
||||||
recovery and signed evidence; #7 requires selection and target testing of an
|
conformance profile; #8 completes the cross-module reference journey and
|
||||||
archive/xdomea provider; and #8 proves the reference journey. Restricted
|
production evidence. Restricted per-record access grants also remain a
|
||||||
per-record access grants also remain a dedicated access-policy slice. No
|
dedicated access-policy slice. Destruction is represented only as an approved
|
||||||
archive or destructive effect is currently claimed.
|
pending state; no content deletion or real archive effect is currently
|
||||||
|
claimed.
|
||||||
|
|
||||||
## Ownership Boundary
|
## Ownership Boundary
|
||||||
|
|
||||||
@@ -103,10 +114,15 @@ planned -> open -> closed -> retention_running -> appraisal_due
|
|||||||
destroyed
|
destroyed
|
||||||
```
|
```
|
||||||
|
|
||||||
Reopening creates a governed transition and does not reset elapsed retention
|
Reopening creates a governed transition and preserves the preceding retention
|
||||||
without an explicit rule. A hold preserves the reason and affected scope. A
|
schedule. A later closure only restarts retention under an explicit
|
||||||
destruction action requires exact manifest, current authority, policy,
|
administrator action. A hold preserves reason, authority, scope, effective
|
||||||
approval, preflight, idempotency, outcome-unknown recovery, and evidence.
|
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
|
## Filing Semantics
|
||||||
|
|
||||||
@@ -189,10 +205,17 @@ available in an evidence/details view.
|
|||||||
storage or an external provider, never node-local paths.
|
storage or an external provider, never node-local paths.
|
||||||
- Every external filing, transfer, or destruction uses intent-before-effect,
|
- Every external filing, transfer, or destruction uses intent-before-effect,
|
||||||
idempotency, durable receipts, outcome-unknown state, and reconciliation.
|
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,
|
- Backup evidence binds record rows, object manifests, provider mappings,
|
||||||
policy/configuration versions, and key references.
|
policy/configuration versions, and key references.
|
||||||
- Restore verifies content digests, missing keys/objects, provider reachability,
|
- Restore verifies content digests, missing keys/objects, provider reachability,
|
||||||
and disposition holds before reopening effects.
|
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
|
- Search indexes are rebuildable projections and cannot become record
|
||||||
authority.
|
authority.
|
||||||
|
|
||||||
@@ -203,8 +226,10 @@ available in an evidence/details view.
|
|||||||
2. Integrate filing from Cases, Forms Runtime, Decisions, Campaign/Postbox,
|
2. Integrate filing from Cases, Forms Runtime, Decisions, Campaign/Postbox,
|
||||||
Files, and Reporting.
|
Files, and Reporting.
|
||||||
3. Add closure, retention calculation, holds, appraisal, and reviewed
|
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.
|
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
|
5. Add destruction/recovery, TR-ESOR provider integration, migration, and
|
||||||
signed reference-journey evidence.
|
signed reference-journey evidence.
|
||||||
|
|
||||||
|
|||||||
@@ -42,16 +42,24 @@ The native kernel currently provides:
|
|||||||
file versions and Cases revisions;
|
file versions and Cases revisions;
|
||||||
- tenant APIs, search projection, uninstall/retirement guards, and a Records
|
- tenant APIs, search projection, uninstall/retirement guards, and a Records
|
||||||
workspace using shared WebUI controls.
|
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
|
Restricted object grants remain a separate governed slice. A target-tested
|
||||||
separate governed slices. Archive transfer and destructive effects are not
|
archive adapter and any destructive effect remain deliberately unimplemented;
|
||||||
implemented by this kernel.
|
approved destruction is only a pending lifecycle state.
|
||||||
|
|
||||||
## First Implementation Slice
|
## First Implementation Slice
|
||||||
|
|
||||||
Complete restricted access, closure and retention calculation, holds,
|
Complete restricted access and one target-tested archive provider without
|
||||||
appraisal, disposition, transfer, recovery evidence, and one target-tested
|
moving source-module ownership into Records. Extend the executable reference
|
||||||
archive provider without moving source-module ownership into Records.
|
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,
|
The complete native/external boundary, temporal and purpose-aware record model,
|
||||||
disposition lifecycle, German public-sector provider profiles, and staged
|
disposition lifecycle, German public-sector provider profiles, and staged
|
||||||
|
|||||||
@@ -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 (
|
from govoplan_records.backend.db.models import (
|
||||||
RecordChronologyEntry,
|
RecordChronologyEntry,
|
||||||
RecordClassRevision,
|
RecordClassRevision,
|
||||||
|
RecordDispositionRevision,
|
||||||
RecordFilePlanRevision,
|
RecordFilePlanRevision,
|
||||||
|
RecordHoldRevision,
|
||||||
RecordIdentity,
|
RecordIdentity,
|
||||||
RecordItem,
|
RecordItem,
|
||||||
RecordRevision,
|
RecordRevision,
|
||||||
|
RecordTransferPackageRevision,
|
||||||
RecordVolumeRevision,
|
RecordVolumeRevision,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"RecordChronologyEntry",
|
"RecordChronologyEntry",
|
||||||
"RecordClassRevision",
|
"RecordClassRevision",
|
||||||
|
"RecordDispositionRevision",
|
||||||
"RecordFilePlanRevision",
|
"RecordFilePlanRevision",
|
||||||
|
"RecordHoldRevision",
|
||||||
"RecordIdentity",
|
"RecordIdentity",
|
||||||
"RecordItem",
|
"RecordItem",
|
||||||
"RecordRevision",
|
"RecordRevision",
|
||||||
|
"RecordTransferPackageRevision",
|
||||||
"RecordVolumeRevision",
|
"RecordVolumeRevision",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -240,6 +240,24 @@ class RecordRevision(Base, TimestampMixin):
|
|||||||
changed_by: Mapped[str | None] = mapped_column(
|
changed_by: Mapped[str | None] = mapped_column(
|
||||||
String(255), nullable=True, index=True
|
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)
|
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)
|
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__ = [
|
__all__ = [
|
||||||
"RecordChronologyEntry",
|
"RecordChronologyEntry",
|
||||||
"RecordClassRevision",
|
"RecordClassRevision",
|
||||||
"RecordFilePlanRevision",
|
"RecordFilePlanRevision",
|
||||||
|
"RecordHoldRevision",
|
||||||
"RecordIdentity",
|
"RecordIdentity",
|
||||||
"RecordItem",
|
"RecordItem",
|
||||||
|
"RecordDispositionRevision",
|
||||||
"RecordRevision",
|
"RecordRevision",
|
||||||
|
"RecordTransferPackageRevision",
|
||||||
"RecordVolumeRevision",
|
"RecordVolumeRevision",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -29,13 +29,20 @@ from govoplan_core.core.modules import (
|
|||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
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.search import SearchSourceProviderRegistration
|
||||||
from govoplan_core.core.views import ViewSurface
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_records.backend.db import models as record_models
|
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.search_source import create_records_search_source
|
||||||
from govoplan_records.backend.service import SqlRecordRegistry
|
from govoplan_records.backend.service import SqlRecordRegistry
|
||||||
|
from govoplan_records.backend.archive import (
|
||||||
|
SIMULATION_PROVIDER_ID,
|
||||||
|
SimulatedRecordArchiveProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
MODULE_ID = "records"
|
MODULE_ID = "records"
|
||||||
@@ -55,6 +62,7 @@ OPTIONAL_DEPENDENCIES = (
|
|||||||
"dms",
|
"dms",
|
||||||
"docs",
|
"docs",
|
||||||
"policy",
|
"policy",
|
||||||
|
"approvals",
|
||||||
"audit",
|
"audit",
|
||||||
"transparency",
|
"transparency",
|
||||||
"search",
|
"search",
|
||||||
@@ -85,6 +93,13 @@ def _records_registry(context: ModuleContext) -> SqlRecordRegistry:
|
|||||||
return SqlRecordRegistry(context.registry)
|
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]:
|
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||||
records = (
|
records = (
|
||||||
session.query(record_models.RecordIdentity)
|
session.query(record_models.RecordIdentity)
|
||||||
@@ -100,7 +115,32 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
|||||||
)
|
)
|
||||||
.count()
|
.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 = (
|
PERMISSIONS = (
|
||||||
@@ -247,14 +287,16 @@ DOCUMENTATION = (
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="records.lifecycle-limitations",
|
id="records.lifecycle",
|
||||||
title="Records lifecycle limitations",
|
title="Governed records lifecycle",
|
||||||
summary="Identifies lifecycle controls intentionally deferred beyond the native kernel.",
|
summary="Close, retain, hold, appraise, approve, and package records with durable evidence.",
|
||||||
body=(
|
body=(
|
||||||
"The current vertical supports planned and open records. Restricted object grants, closure, "
|
"Closing a record applies the versioned record-class retention rule. Reopening preserves the "
|
||||||
"retention calculation, holds, appraisal, disposition, transfer, destruction, and external "
|
"previous schedule unless an administrator deliberately restarts it. Effective-dated holds block "
|
||||||
"archive effects are separate governed work packages. No destructive effect is implied by "
|
"disposition and transfer. Appraisal selects retain, transfer, destroy, or reclassify; a disposition "
|
||||||
"enabling Records."
|
"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",
|
layer="configured",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
@@ -263,17 +305,44 @@ DOCUMENTATION = (
|
|||||||
related_modules=("policy", "approvals", "audit", "dms"),
|
related_modules=("policy", "approvals", "audit", "dms"),
|
||||||
translations={
|
translations={
|
||||||
"de": {
|
"de": {
|
||||||
"title": "Grenzen des Aktenlebenszyklus",
|
"title": "Gesteuerter Aktenlebenszyklus",
|
||||||
"summary": "Kennzeichnet bewusst nach dem nativen Kern umzusetzende Lebenszyklussteuerungen.",
|
"summary": "Akten mit dauerhaftem Nachweis abschließen, aufbewahren, sperren, bewerten, freigeben und paketieren.",
|
||||||
"body": (
|
"body": (
|
||||||
"Der aktuelle Stand unterstützt geplante und offene Akten. Objektbezogene Freigaben, "
|
"Beim Abschluss einer Akte wird die versionierte Aufbewahrungsregel der Aktenklasse angewendet. "
|
||||||
"Abschluss, Aufbewahrungsberechnung, Sperren, Bewertung, Aussonderung, Übergabe, Vernichtung "
|
"Eine Wiedereröffnung bewahrt den bisherigen Zeitplan, sofern ein Administrator ihn nicht "
|
||||||
"und externe Archiveffekte sind getrennte gesteuerte Arbeitspakete. Die Aktivierung von "
|
"bewusst neu startet. Gültigkeitsbezogene Sperren blockieren Aussonderung und Übergabe. Die "
|
||||||
"Records löst keine vernichtende Wirkung aus."
|
"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=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name="records.registry", version="1.0.0"),
|
ModuleInterfaceProvider(name="records.registry", version="1.0.0"),
|
||||||
ModuleInterfaceProvider(name="records.filing", 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_documentation={
|
||||||
CAPABILITY_RECORDS_FILING: CapabilityDocumentation(
|
CAPABILITY_RECORDS_FILING: CapabilityDocumentation(
|
||||||
label="Record filing",
|
label="Record filing",
|
||||||
summary="Resolves authorized exact source revisions and files immutable record items.",
|
summary="Resolves authorized exact source revisions and files immutable record items.",
|
||||||
contract_version="1.0.0",
|
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(
|
migration_spec=MigrationSpec(
|
||||||
module_id=MODULE_ID,
|
module_id=MODULE_ID,
|
||||||
@@ -377,6 +457,9 @@ manifest = ModuleManifest(
|
|||||||
retirement_supported=True,
|
retirement_supported=True,
|
||||||
retirement_provider=drop_table_retirement_provider(
|
retirement_provider=drop_table_retirement_provider(
|
||||||
record_models.RecordChronologyEntry,
|
record_models.RecordChronologyEntry,
|
||||||
|
record_models.RecordTransferPackageRevision,
|
||||||
|
record_models.RecordDispositionRevision,
|
||||||
|
record_models.RecordHoldRevision,
|
||||||
record_models.RecordItem,
|
record_models.RecordItem,
|
||||||
record_models.RecordVolumeRevision,
|
record_models.RecordVolumeRevision,
|
||||||
record_models.RecordRevision,
|
record_models.RecordRevision,
|
||||||
@@ -396,6 +479,9 @@ manifest = ModuleManifest(
|
|||||||
record_models.RecordRevision,
|
record_models.RecordRevision,
|
||||||
record_models.RecordItem,
|
record_models.RecordItem,
|
||||||
record_models.RecordChronologyEntry,
|
record_models.RecordChronologyEntry,
|
||||||
|
record_models.RecordHoldRevision,
|
||||||
|
record_models.RecordDispositionRevision,
|
||||||
|
record_models.RecordTransferPackageRevision,
|
||||||
record_models.RecordClassRevision,
|
record_models.RecordClassRevision,
|
||||||
record_models.RecordFilePlanRevision,
|
record_models.RecordFilePlanRevision,
|
||||||
label="Records",
|
label="Records",
|
||||||
@@ -417,6 +503,9 @@ manifest = ModuleManifest(
|
|||||||
"record_item",
|
"record_item",
|
||||||
"record_class",
|
"record_class",
|
||||||
"file_plan_node",
|
"file_plan_node",
|
||||||
|
"record_hold",
|
||||||
|
"record_disposition",
|
||||||
|
"record_transfer_package",
|
||||||
),
|
),
|
||||||
evidence=(
|
evidence=(
|
||||||
"src/govoplan_records/backend/service.py",
|
"src/govoplan_records/backend/service.py",
|
||||||
@@ -433,10 +522,16 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
retention=InformationGovernanceDimension(
|
retention=InformationGovernanceDimension(
|
||||||
adoption="contract_only",
|
adoption="enforced",
|
||||||
limitation=(
|
object_types=(
|
||||||
"Record classes preserve retention inputs; closure, holds, calculation, appraisal, "
|
"record",
|
||||||
"and disposition are tracked in Records #5."
|
"record_class",
|
||||||
|
"record_hold",
|
||||||
|
"record_disposition",
|
||||||
|
),
|
||||||
|
evidence=(
|
||||||
|
"src/govoplan_records/backend/service.py",
|
||||||
|
"tests/test_records.py",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
institutional_context=InformationGovernanceDimension(
|
institutional_context=InformationGovernanceDimension(
|
||||||
@@ -462,8 +557,8 @@ manifest = ModuleManifest(
|
|||||||
documentation_ref="docs/EAKTE_ARCHITECTURE.md",
|
documentation_ref="docs/EAKTE_ARCHITECTURE.md",
|
||||||
test_ref="tests/test_records.py",
|
test_ref="tests/test_records.py",
|
||||||
known_limits=(
|
known_limits=(
|
||||||
"Restricted object grants and lifecycle stages after open are tracked separately.",
|
"Restricted object grants are tracked separately.",
|
||||||
"Archive transfer and destructive effects are not part of the native kernel.",
|
"Archive packaging is native, but real target conformance and destructive effects remain external work.",
|
||||||
),
|
),
|
||||||
supported_authority_modes=(
|
supported_authority_modes=(
|
||||||
"native_authoritative",
|
"native_authoritative",
|
||||||
@@ -481,6 +576,10 @@ manifest = ModuleManifest(
|
|||||||
"record item",
|
"record item",
|
||||||
"filing decision",
|
"filing decision",
|
||||||
"record chronology",
|
"record chronology",
|
||||||
|
"record hold",
|
||||||
|
"record appraisal",
|
||||||
|
"record disposition",
|
||||||
|
"record transfer package",
|
||||||
),
|
),
|
||||||
non_owned_concepts=(
|
non_owned_concepts=(
|
||||||
"file content",
|
"file content",
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
"""Add governed Records lifecycle evidence.
|
||||||
|
|
||||||
|
Revision ID: 7f5b3d9a2c1e
|
||||||
|
Revises: 6e4a2c8f1d9b
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "7f5b3d9a2c1e"
|
||||||
|
down_revision = "6e4a2c8f1d9b"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("record_revisions") as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True)
|
||||||
|
)
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column("retention_started_at", sa.DateTime(timezone=True), nullable=True)
|
||||||
|
)
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column("retention_due_at", sa.DateTime(timezone=True), nullable=True)
|
||||||
|
)
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column(
|
||||||
|
"retention_rule",
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("'{}'"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column("appraisal_state", sa.String(length=40), nullable=True)
|
||||||
|
)
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column(
|
||||||
|
"appraisal",
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("'{}'"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
batch_op.create_index(
|
||||||
|
op.f("ix_record_revisions_closed_at"), ["closed_at"], unique=False
|
||||||
|
)
|
||||||
|
batch_op.create_index(
|
||||||
|
op.f("ix_record_revisions_retention_started_at"),
|
||||||
|
["retention_started_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
batch_op.create_index(
|
||||||
|
op.f("ix_record_revisions_retention_due_at"),
|
||||||
|
["retention_due_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
batch_op.create_index(
|
||||||
|
op.f("ix_record_revisions_appraisal_state"),
|
||||||
|
["appraisal_state"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"record_hold_revisions",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("hold_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("record_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("reason", sa.Text(), nullable=False),
|
||||||
|
sa.Column("authority", sa.String(length=500), nullable=False),
|
||||||
|
sa.Column("scope", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("effective_from", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("effective_to", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("released_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("policy_refs", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("institutional_context", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("changed_by", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["previous_revision_id"],
|
||||||
|
["record_hold_revisions.id"],
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id", "hold_id", "revision", name="uq_record_hold_revision"
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id", "idempotency_key", name="uq_record_hold_idempotency"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_indexes(
|
||||||
|
"record_hold_revisions",
|
||||||
|
"tenant_id",
|
||||||
|
"hold_id",
|
||||||
|
"record_id",
|
||||||
|
"previous_revision_id",
|
||||||
|
"status",
|
||||||
|
"effective_from",
|
||||||
|
"effective_to",
|
||||||
|
"recorded_at",
|
||||||
|
"superseded_at",
|
||||||
|
"changed_by",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_record_hold_current",
|
||||||
|
"record_hold_revisions",
|
||||||
|
["tenant_id", "hold_id", "superseded_at"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_record_hold_record",
|
||||||
|
"record_hold_revisions",
|
||||||
|
["tenant_id", "record_id", "status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"record_disposition_revisions",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("disposition_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("record_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("action", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("reason", sa.Text(), nullable=False),
|
||||||
|
sa.Column("subject_revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("subject_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("consequence_preview", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("policy_refs", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("approval_request_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("proposed_by", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("reviewed_by", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("institutional_context", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["previous_revision_id"],
|
||||||
|
["record_disposition_revisions.id"],
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"disposition_id",
|
||||||
|
"revision",
|
||||||
|
name="uq_record_disposition_revision",
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_record_disposition_idempotency",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_indexes(
|
||||||
|
"record_disposition_revisions",
|
||||||
|
"tenant_id",
|
||||||
|
"disposition_id",
|
||||||
|
"record_id",
|
||||||
|
"previous_revision_id",
|
||||||
|
"action",
|
||||||
|
"status",
|
||||||
|
"approval_request_id",
|
||||||
|
"proposed_by",
|
||||||
|
"reviewed_by",
|
||||||
|
"recorded_at",
|
||||||
|
"superseded_at",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_record_disposition_current",
|
||||||
|
"record_disposition_revisions",
|
||||||
|
["tenant_id", "disposition_id", "superseded_at"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_record_disposition_record",
|
||||||
|
"record_disposition_revisions",
|
||||||
|
["tenant_id", "record_id", "status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"record_transfer_package_revisions",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("package_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("record_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("disposition_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("record_revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("provider_id", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("profile", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("authority_mode", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("manifest", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("manifest_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("receipt", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("receipt_sha256", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("external_reference", sa.String(length=1500), nullable=True),
|
||||||
|
sa.Column("recovery_operation_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("simulated", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("institutional_context", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("changed_by", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["previous_revision_id"],
|
||||||
|
["record_transfer_package_revisions.id"],
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"package_id",
|
||||||
|
"revision",
|
||||||
|
name="uq_record_transfer_package_revision",
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_record_transfer_package_idempotency",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_indexes(
|
||||||
|
"record_transfer_package_revisions",
|
||||||
|
"tenant_id",
|
||||||
|
"package_id",
|
||||||
|
"record_id",
|
||||||
|
"disposition_id",
|
||||||
|
"previous_revision_id",
|
||||||
|
"provider_id",
|
||||||
|
"status",
|
||||||
|
"recovery_operation_id",
|
||||||
|
"recorded_at",
|
||||||
|
"superseded_at",
|
||||||
|
"changed_by",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_record_transfer_package_current",
|
||||||
|
"record_transfer_package_revisions",
|
||||||
|
["tenant_id", "package_id", "superseded_at"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_record_transfer_package_record",
|
||||||
|
"record_transfer_package_revisions",
|
||||||
|
["tenant_id", "record_id", "status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("record_transfer_package_revisions")
|
||||||
|
op.drop_table("record_disposition_revisions")
|
||||||
|
op.drop_table("record_hold_revisions")
|
||||||
|
|
||||||
|
with op.batch_alter_table("record_revisions") as batch_op:
|
||||||
|
batch_op.drop_index(op.f("ix_record_revisions_appraisal_state"))
|
||||||
|
batch_op.drop_index(op.f("ix_record_revisions_retention_due_at"))
|
||||||
|
batch_op.drop_index(op.f("ix_record_revisions_retention_started_at"))
|
||||||
|
batch_op.drop_index(op.f("ix_record_revisions_closed_at"))
|
||||||
|
batch_op.drop_column("appraisal")
|
||||||
|
batch_op.drop_column("appraisal_state")
|
||||||
|
batch_op.drop_column("retention_rule")
|
||||||
|
batch_op.drop_column("retention_due_at")
|
||||||
|
batch_op.drop_column("retention_started_at")
|
||||||
|
batch_op.drop_column("closed_at")
|
||||||
|
|
||||||
|
|
||||||
|
def _indexes(table: str, *columns: str) -> None:
|
||||||
|
for column in columns:
|
||||||
|
op.create_index(op.f(f"ix_{table}_{column}"), table, [column], unique=False)
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from contextvars import ContextVar, Token
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from govoplan_core.core.recovery import (
|
||||||
|
RecoveryGuaranteeError,
|
||||||
|
RecoveryMode,
|
||||||
|
RecoveryPlan,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.recovery_runtime import (
|
||||||
|
DurableRecoveryOperation,
|
||||||
|
RecoveryOperationBusy,
|
||||||
|
RecoveryOperationStateConflict,
|
||||||
|
begin_durable_recovery_operation,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.runtime_coordination import process_runtime_identity
|
||||||
|
|
||||||
|
|
||||||
|
class RecordRecoveryError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_recovery_operation_id: ContextVar[str | None] = ContextVar(
|
||||||
|
"records_recovery_operation_id", default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def bind_record_recovery_operation(operation_id: str) -> Token[str | None]:
|
||||||
|
return _recovery_operation_id.set(operation_id)
|
||||||
|
|
||||||
|
|
||||||
|
def current_record_recovery_operation() -> str | None:
|
||||||
|
return _recovery_operation_id.get()
|
||||||
|
|
||||||
|
|
||||||
|
def reset_record_recovery_operation(token: Token[str | None]) -> None:
|
||||||
|
_recovery_operation_id.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def record_session_factory(session: Session) -> sessionmaker[Session]:
|
||||||
|
bind = session.get_bind()
|
||||||
|
if bind is None:
|
||||||
|
raise RecordRecoveryError("Records recovery requires a bound database session.")
|
||||||
|
return sessionmaker(bind=bind, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class RecordAtomicRecovery:
|
||||||
|
operation: DurableRecoveryOperation | None
|
||||||
|
operation_id: str
|
||||||
|
replayed: bool
|
||||||
|
|
||||||
|
def commit_success(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
result: object,
|
||||||
|
resource_id: str,
|
||||||
|
) -> None:
|
||||||
|
evidence = {
|
||||||
|
"verified": True,
|
||||||
|
"resource_id": resource_id,
|
||||||
|
"result_sha256": _canonical_sha256(result),
|
||||||
|
"domain_and_checkpoint_atomic": True,
|
||||||
|
"checks": {
|
||||||
|
"domain_result_digest_recorded": True,
|
||||||
|
"domain_and_checkpoint_atomic": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if self.operation is None:
|
||||||
|
session.commit()
|
||||||
|
return
|
||||||
|
self.operation.commit_atomic_success(session, evidence=evidence)
|
||||||
|
|
||||||
|
def reject(self, *, summary: str, error_type: str) -> None:
|
||||||
|
if self.operation is None:
|
||||||
|
return
|
||||||
|
self.operation.reject(
|
||||||
|
summary=summary,
|
||||||
|
evidence={
|
||||||
|
"verified": True,
|
||||||
|
"domain_mutation_committed": False,
|
||||||
|
"error_type": error_type,
|
||||||
|
"checks": {"definitive_domain_rejection": True},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def fail(self, *, summary: str, error_type: str) -> None:
|
||||||
|
if self.operation is None:
|
||||||
|
return
|
||||||
|
self.operation.fail(
|
||||||
|
summary=summary,
|
||||||
|
evidence={
|
||||||
|
"verified": True,
|
||||||
|
"domain_mutation_committed": False,
|
||||||
|
"error_type": error_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def begin_record_atomic_recovery(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
operation_type: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
request: dict[str, Any],
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
) -> RecordAtomicRecovery:
|
||||||
|
operation_key = hashlib.sha256(
|
||||||
|
f"{tenant_id}:{operation_type}:{idempotency_key}".encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
lease_id = hashlib.sha256(
|
||||||
|
f"{tenant_id}:{resource_type}:{resource_id}".encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
try:
|
||||||
|
started = begin_durable_recovery_operation(
|
||||||
|
record_session_factory(session),
|
||||||
|
identity=process_runtime_identity(),
|
||||||
|
module_id="records",
|
||||||
|
operation_type=operation_type,
|
||||||
|
idempotency_key=f"records:{operation_key}",
|
||||||
|
request={"tenant_id": tenant_id, **request},
|
||||||
|
recovery_plan=RecoveryPlan(
|
||||||
|
mode=RecoveryMode.ATOMIC,
|
||||||
|
preconditions=(
|
||||||
|
"the current actor is authorized for the Records mutation",
|
||||||
|
"the expected revision and idempotency key are present",
|
||||||
|
"the target resource has no unresolved recovery operation",
|
||||||
|
),
|
||||||
|
verification_steps=(
|
||||||
|
"commit the immutable domain revision and chronology entry",
|
||||||
|
"commit the terminal recovery checkpoint in the same transaction",
|
||||||
|
"verify the result digest and recovery evidence chain",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
precondition_evidence={
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"resource_type": resource_type,
|
||||||
|
"resource_id_sha256": hashlib.sha256(
|
||||||
|
resource_id.encode("utf-8")
|
||||||
|
).hexdigest(),
|
||||||
|
"request_sha256": _canonical_sha256(request),
|
||||||
|
"external_effect": False,
|
||||||
|
},
|
||||||
|
lease_resource_key=f"records:{tenant_id}:{lease_id}",
|
||||||
|
lease_ttl_seconds=5 * 60,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
metadata={
|
||||||
|
"resources": ["postgresql"],
|
||||||
|
"external_effect": False,
|
||||||
|
"recovery_declaration": "records-atomic-revision",
|
||||||
|
},
|
||||||
|
block_unresolved_resource=True,
|
||||||
|
)
|
||||||
|
except RecoveryOperationBusy as exc:
|
||||||
|
raise RecordRecoveryError(
|
||||||
|
"Another runtime is changing this Records resource."
|
||||||
|
) from exc
|
||||||
|
except RecoveryOperationStateConflict as exc:
|
||||||
|
raise RecordRecoveryError(
|
||||||
|
"This Records resource has an active or unresolved recovery operation."
|
||||||
|
) from exc
|
||||||
|
except (RecoveryGuaranteeError, RuntimeError) as exc:
|
||||||
|
raise RecordRecoveryError(
|
||||||
|
"The recovery ledger is unavailable; the Records mutation was not started."
|
||||||
|
) from exc
|
||||||
|
return RecordAtomicRecovery(
|
||||||
|
operation=started.operation,
|
||||||
|
operation_id=started.operation_id,
|
||||||
|
replayed=started.replayed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_sha256(value: object) -> str:
|
||||||
|
return hashlib.sha256(
|
||||||
|
json.dumps(
|
||||||
|
value,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=False,
|
||||||
|
default=str,
|
||||||
|
).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"RecordAtomicRecovery",
|
||||||
|
"RecordRecoveryError",
|
||||||
|
"begin_record_atomic_recovery",
|
||||||
|
"bind_record_recovery_operation",
|
||||||
|
"current_record_recovery_operation",
|
||||||
|
"record_session_factory",
|
||||||
|
"reset_record_recovery_operation",
|
||||||
|
]
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
@@ -7,18 +8,31 @@ from sqlalchemy.exc import IntegrityError
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
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.core.records import RecordFilingRequest, RecordSourceLocator
|
||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
from govoplan_records.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE
|
from govoplan_records.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE
|
||||||
from govoplan_records.backend.schemas import (
|
from govoplan_records.backend.schemas import (
|
||||||
FilePlanNodeWriteRequest,
|
FilePlanNodeWriteRequest,
|
||||||
|
RecordAppraisalRequest,
|
||||||
|
RecordArchiveProviderResponse,
|
||||||
RecordCatalogResponse,
|
RecordCatalogResponse,
|
||||||
RecordClassWriteRequest,
|
RecordClassWriteRequest,
|
||||||
|
RecordCloseRequest,
|
||||||
RecordCreateRequest,
|
RecordCreateRequest,
|
||||||
RecordDetailResponse,
|
RecordDetailResponse,
|
||||||
|
RecordDispositionCreateRequest,
|
||||||
|
RecordDispositionFinalizeRequest,
|
||||||
|
RecordDispositionWithdrawRequest,
|
||||||
|
RecordHoldCreateRequest,
|
||||||
|
RecordHoldReleaseRequest,
|
||||||
RecordItemCreateRequest,
|
RecordItemCreateRequest,
|
||||||
|
RecordLifecycleActionRequest,
|
||||||
RecordListResponse,
|
RecordListResponse,
|
||||||
|
RecordRecoveryStatusResponse,
|
||||||
RecordSourceProviderResponse,
|
RecordSourceProviderResponse,
|
||||||
|
RecordTransferDispatchRequest,
|
||||||
|
RecordTransferPackageCreateRequest,
|
||||||
RecordUpdateRequest,
|
RecordUpdateRequest,
|
||||||
RecordVolumeCreateRequest,
|
RecordVolumeCreateRequest,
|
||||||
)
|
)
|
||||||
@@ -29,6 +43,12 @@ from govoplan_records.backend.service import (
|
|||||||
RecordStoreError,
|
RecordStoreError,
|
||||||
SqlRecordRegistry,
|
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:
|
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(
|
lambda: records.write_file_plan_node(
|
||||||
session, principal, payload=payload.model_dump(mode="python")
|
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(
|
@router.post(
|
||||||
@@ -77,6 +103,12 @@ def create_router(registry: object | None = None) -> APIRouter:
|
|||||||
lambda: records.write_record_class(
|
lambda: records.write_record_class(
|
||||||
session, principal, payload=payload.model_dump(mode="python")
|
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)
|
@router.get("/sources", response_model=RecordSourceProviderResponse)
|
||||||
@@ -89,6 +121,16 @@ def create_router(registry: object | None = None) -> APIRouter:
|
|||||||
providers=records.source_providers(session, principal)
|
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)
|
@router.get("", response_model=RecordListResponse)
|
||||||
def api_list_records(
|
def api_list_records(
|
||||||
query: str | None = Query(default=None, max_length=500),
|
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(
|
lambda: records.create_record(
|
||||||
session, principal, payload=payload.model_dump(mode="python")
|
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)
|
@router.get("/{record_id}", response_model=RecordDetailResponse)
|
||||||
@@ -146,6 +194,20 @@ def create_router(registry: object | None = None) -> APIRouter:
|
|||||||
except RecordStoreError as exc:
|
except RecordStoreError as exc:
|
||||||
raise _http_error(exc) from 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])
|
@router.patch("/{record_id}", response_model=dict[str, Any])
|
||||||
def api_update_record(
|
def api_update_record(
|
||||||
record_id: str,
|
record_id: str,
|
||||||
@@ -162,6 +224,294 @@ def create_router(registry: object | None = None) -> APIRouter:
|
|||||||
record_id=record_id,
|
record_id=record_id,
|
||||||
payload=payload.model_dump(mode="python", exclude_unset=True),
|
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(
|
@router.post(
|
||||||
@@ -184,6 +534,12 @@ def create_router(registry: object | None = None) -> APIRouter:
|
|||||||
record_id=record_id,
|
record_id=record_id,
|
||||||
payload=payload.model_dump(mode="python"),
|
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(
|
@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
|
return router
|
||||||
|
|
||||||
@@ -245,18 +610,69 @@ def _require(principal: ApiPrincipal, scope: str) -> None:
|
|||||||
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
|
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:
|
try:
|
||||||
result = operation()
|
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
|
return result
|
||||||
except (RecordStoreError, IntegrityError) as exc:
|
except (RecordStoreError, IntegrityError) as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
|
if recovery is not None:
|
||||||
|
recovery.reject(summary=str(exc), error_type=type(exc).__name__)
|
||||||
if isinstance(exc, IntegrityError):
|
if isinstance(exc, IntegrityError):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=409, detail="The record write conflicts with existing data."
|
status_code=409, detail="The record write conflicts with existing data."
|
||||||
) from exc
|
) from exc
|
||||||
raise _http_error(exc) 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:
|
def _http_error(exc: RecordStoreError) -> HTTPException:
|
||||||
|
|||||||
@@ -148,6 +148,99 @@ class RecordItemCreateRequest(StrictModel):
|
|||||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
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):
|
class RecordListResponse(StrictModel):
|
||||||
records: list[dict[str, Any]]
|
records: list[dict[str, Any]]
|
||||||
total: int
|
total: int
|
||||||
@@ -165,6 +258,9 @@ class RecordDetailResponse(StrictModel):
|
|||||||
volumes: list[dict[str, Any]]
|
volumes: list[dict[str, Any]]
|
||||||
items: list[dict[str, Any]]
|
items: list[dict[str, Any]]
|
||||||
chronology: 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]
|
access_explanation: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
@@ -172,15 +268,43 @@ class RecordSourceProviderResponse(StrictModel):
|
|||||||
providers: list[dict[str, Any]]
|
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__ = [
|
__all__ = [
|
||||||
"FilePlanNodeWriteRequest",
|
"FilePlanNodeWriteRequest",
|
||||||
|
"RecordAppraisalRequest",
|
||||||
|
"RecordArchiveProviderResponse",
|
||||||
"RecordCatalogResponse",
|
"RecordCatalogResponse",
|
||||||
"RecordClassWriteRequest",
|
"RecordClassWriteRequest",
|
||||||
|
"RecordCloseRequest",
|
||||||
"RecordCreateRequest",
|
"RecordCreateRequest",
|
||||||
"RecordDetailResponse",
|
"RecordDetailResponse",
|
||||||
|
"RecordDispositionCreateRequest",
|
||||||
|
"RecordDispositionFinalizeRequest",
|
||||||
|
"RecordDispositionWithdrawRequest",
|
||||||
|
"RecordHoldCreateRequest",
|
||||||
|
"RecordHoldReleaseRequest",
|
||||||
"RecordItemCreateRequest",
|
"RecordItemCreateRequest",
|
||||||
|
"RecordLifecycleActionRequest",
|
||||||
"RecordListResponse",
|
"RecordListResponse",
|
||||||
|
"RecordRecoveryStatusResponse",
|
||||||
"RecordSourceProviderResponse",
|
"RecordSourceProviderResponse",
|
||||||
|
"RecordTransferDispatchRequest",
|
||||||
|
"RecordTransferPackageCreateRequest",
|
||||||
"RecordUpdateRequest",
|
"RecordUpdateRequest",
|
||||||
"RecordVolumeCreateRequest",
|
"RecordVolumeCreateRequest",
|
||||||
]
|
]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+18
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,9 +30,12 @@ class RecordsMigrationTests(unittest.TestCase):
|
|||||||
"record_chronology_entries",
|
"record_chronology_entries",
|
||||||
"record_class_revisions",
|
"record_class_revisions",
|
||||||
"record_file_plan_revisions",
|
"record_file_plan_revisions",
|
||||||
|
"record_hold_revisions",
|
||||||
"record_identities",
|
"record_identities",
|
||||||
"record_items",
|
"record_items",
|
||||||
|
"record_disposition_revisions",
|
||||||
"record_revisions",
|
"record_revisions",
|
||||||
|
"record_transfer_package_revisions",
|
||||||
"record_volume_revisions",
|
"record_volume_revisions",
|
||||||
}.issubset(inspector.get_table_names())
|
}.issubset(inspector.get_table_names())
|
||||||
)
|
)
|
||||||
@@ -42,7 +45,7 @@ class RecordsMigrationTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
with engine.connect() as connection:
|
with engine.connect() as connection:
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"6e4a2c8f1d9b",
|
"7f5b3d9a2c1e",
|
||||||
set(MigrationContext.configure(connection).get_current_heads()),
|
set(MigrationContext.configure(connection).get_current_heads()),
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
+416
-5
@@ -2,16 +2,22 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.core.records import (
|
from govoplan_core.core.records import (
|
||||||
|
RecordArchiveProviderState,
|
||||||
|
RecordArchiveReceipt,
|
||||||
RecordFilingRequest,
|
RecordFilingRequest,
|
||||||
RecordSourceLocator,
|
RecordSourceLocator,
|
||||||
RecordSourceReference,
|
RecordSourceReference,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.recovery import RecoveryCheckpoint, RecoveryOperation
|
||||||
|
from govoplan_core.core.approvals import ApprovalCheck, ApprovalRequestRef
|
||||||
from govoplan_core.core.temporal import (
|
from govoplan_core.core.temporal import (
|
||||||
TemporalDataContext,
|
TemporalDataContext,
|
||||||
bind_temporal_data_context,
|
bind_temporal_data_context,
|
||||||
@@ -20,12 +26,16 @@ from govoplan_core.core.temporal import (
|
|||||||
from govoplan_records.backend.db.models import (
|
from govoplan_records.backend.db.models import (
|
||||||
RecordChronologyEntry,
|
RecordChronologyEntry,
|
||||||
RecordClassRevision,
|
RecordClassRevision,
|
||||||
|
RecordDispositionRevision,
|
||||||
RecordFilePlanRevision,
|
RecordFilePlanRevision,
|
||||||
|
RecordHoldRevision,
|
||||||
RecordIdentity,
|
RecordIdentity,
|
||||||
RecordItem,
|
RecordItem,
|
||||||
RecordRevision,
|
RecordRevision,
|
||||||
|
RecordTransferPackageRevision,
|
||||||
RecordVolumeRevision,
|
RecordVolumeRevision,
|
||||||
)
|
)
|
||||||
|
from govoplan_records.backend.archive import SimulatedRecordArchiveProvider
|
||||||
from govoplan_records.backend.service import RecordConflictError, SqlRecordRegistry
|
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:
|
class Registry:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.provider = SourceProvider()
|
self.provider = SourceProvider()
|
||||||
|
self.archive_provider = SimulatedRecordArchiveProvider()
|
||||||
|
self.unknown_archive_provider = UnknownOutcomeArchiveProvider()
|
||||||
|
self.approvals = ApprovalProvider()
|
||||||
|
|
||||||
def capability_names(self):
|
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):
|
def tenant_capability(self, name, session, *, tenant_id):
|
||||||
del session
|
del session
|
||||||
return (
|
if tenant_id != "tenant-1":
|
||||||
self.provider
|
return None
|
||||||
if name == "records.source.files" and tenant_id == "tenant-1"
|
return {
|
||||||
else None
|
"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):
|
class RecordsTests(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
@@ -90,6 +193,11 @@ class RecordsTests(unittest.TestCase):
|
|||||||
RecordVolumeRevision.__table__,
|
RecordVolumeRevision.__table__,
|
||||||
RecordItem.__table__,
|
RecordItem.__table__,
|
||||||
RecordChronologyEntry.__table__,
|
RecordChronologyEntry.__table__,
|
||||||
|
RecordHoldRevision.__table__,
|
||||||
|
RecordDispositionRevision.__table__,
|
||||||
|
RecordTransferPackageRevision.__table__,
|
||||||
|
RecoveryOperation.__table__,
|
||||||
|
RecoveryCheckpoint.__table__,
|
||||||
):
|
):
|
||||||
table.create(self.engine)
|
table.create(self.engine)
|
||||||
self.session = Session(self.engine)
|
self.session = Session(self.engine)
|
||||||
@@ -400,6 +508,309 @@ class RecordsTests(unittest.TestCase):
|
|||||||
self.records.source_providers(self.session, self.principal),
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -12,6 +12,12 @@ export type FilePlanNode = {
|
|||||||
valid_from?: string | null;
|
valid_from?: string | null;
|
||||||
valid_to?: string | null;
|
valid_to?: string | null;
|
||||||
recorded_at: string;
|
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>;
|
institutional_context: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -99,11 +105,63 @@ export type RecordChronology = {
|
|||||||
payload: Record<string, unknown>;
|
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 = {
|
export type RecordDetail = {
|
||||||
record: RecordEntry;
|
record: RecordEntry;
|
||||||
volumes: RecordVolume[];
|
volumes: RecordVolume[];
|
||||||
items: RecordItem[];
|
items: RecordItem[];
|
||||||
chronology: RecordChronology[];
|
chronology: RecordChronology[];
|
||||||
|
holds: RecordHold[];
|
||||||
|
dispositions: RecordDisposition[];
|
||||||
|
transfer_packages: RecordTransferPackage[];
|
||||||
access_explanation: {
|
access_explanation: {
|
||||||
decision: string;
|
decision: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
@@ -120,6 +178,19 @@ export type RecordSourceProvider = {
|
|||||||
resource_types: string[];
|
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(
|
export function listRecords(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
options: {
|
options: {
|
||||||
@@ -154,6 +225,24 @@ export function getRecordSources(settings: ApiSettings, signal?: AbortSignal): P
|
|||||||
return apiFetch(settings, "/api/v1/records/sources", { signal });
|
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> {
|
export function createRecord(settings: ApiSettings, payload: Record<string, unknown>): Promise<RecordEntry> {
|
||||||
return apiFetch(settings, "/api/v1/records", {
|
return apiFetch(settings, "/api/v1/records", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -182,3 +271,99 @@ export function fileRecordItem(
|
|||||||
body: JSON.stringify(payload)
|
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;
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search
|
Search,
|
||||||
|
Settings2
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -33,17 +34,21 @@ import {
|
|||||||
createRecord,
|
createRecord,
|
||||||
fileRecordItem,
|
fileRecordItem,
|
||||||
getRecord,
|
getRecord,
|
||||||
|
getRecordArchiveProviders,
|
||||||
getRecordCatalog,
|
getRecordCatalog,
|
||||||
getRecordSources,
|
getRecordSources,
|
||||||
listRecords,
|
listRecords,
|
||||||
updateRecord,
|
updateRecord,
|
||||||
type FilePlanNode,
|
type FilePlanNode,
|
||||||
|
type RecordArchiveProvider,
|
||||||
type RecordCatalog,
|
type RecordCatalog,
|
||||||
type RecordClass,
|
type RecordClass,
|
||||||
type RecordDetail,
|
type RecordDetail,
|
||||||
type RecordEntry,
|
type RecordEntry,
|
||||||
type RecordSourceProvider
|
type RecordSourceProvider
|
||||||
} from "../../api/records";
|
} from "../../api/records";
|
||||||
|
import { RecordCatalogDialog } from "./RecordCatalogDialog";
|
||||||
|
import { RecordLifecyclePanel } from "./RecordLifecyclePanel";
|
||||||
import {
|
import {
|
||||||
RECORDS_DOCUMENTATION,
|
RECORDS_DOCUMENTATION,
|
||||||
RECORDS_FIELD_DOCUMENTATION
|
RECORDS_FIELD_DOCUMENTATION
|
||||||
@@ -68,6 +73,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
);
|
);
|
||||||
const [detail, setDetail] = useState<RecordDetail | null>(null);
|
const [detail, setDetail] = useState<RecordDetail | null>(null);
|
||||||
const [sources, setSources] = useState<RecordSourceProvider[]>([]);
|
const [sources, setSources] = useState<RecordSourceProvider[]>([]);
|
||||||
|
const [archiveProviders, setArchiveProviders] = useState<RecordArchiveProvider[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -76,8 +82,10 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [fileOpen, setFileOpen] = useState(false);
|
const [fileOpen, setFileOpen] = useState(false);
|
||||||
|
const [catalogOpen, setCatalogOpen] = useState(false);
|
||||||
const canWrite = auth.scopes.includes("records:workspace:write") ||
|
const canWrite = auth.scopes.includes("records:workspace:write") ||
|
||||||
auth.scopes.includes("records:workspace:admin");
|
auth.scopes.includes("records:workspace:admin");
|
||||||
|
const canAdmin = auth.scopes.includes("records:workspace:admin");
|
||||||
|
|
||||||
const reload = useCallback(() => setReloadKey((value) => value + 1), []);
|
const reload = useCallback(() => setReloadKey((value) => value + 1), []);
|
||||||
|
|
||||||
@@ -88,11 +96,15 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
getRecordCatalog(settings, controller.signal),
|
getRecordCatalog(settings, controller.signal),
|
||||||
canWrite
|
canWrite
|
||||||
? getRecordSources(settings, controller.signal)
|
? 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);
|
setCatalog(nextCatalog);
|
||||||
setSources(sourceResult.providers);
|
setSources(sourceResult.providers);
|
||||||
|
setArchiveProviders(archiveResult.providers);
|
||||||
}).
|
}).
|
||||||
catch((reason) => {
|
catch((reason) => {
|
||||||
if ((reason as Error).name !== "AbortError") {
|
if ((reason as Error).name !== "AbortError") {
|
||||||
@@ -227,6 +239,12 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
<RefreshCw size={16} aria-hidden="true" />
|
<RefreshCw size={16} aria-hidden="true" />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</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}>
|
<form className="records-search" onSubmit={submitSearch}>
|
||||||
<Search size={17} aria-hidden="true" />
|
<Search size={17} aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
@@ -335,10 +353,14 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
<RecordDetailPanel
|
<RecordDetailPanel
|
||||||
detail={detail}
|
detail={detail}
|
||||||
recordClass={classesById.get(detail.record.class_id)}
|
recordClass={classesById.get(detail.record.class_id)}
|
||||||
|
settings={settings}
|
||||||
|
archiveProviders={archiveProviders}
|
||||||
canWrite={canWrite}
|
canWrite={canWrite}
|
||||||
|
canAdmin={canAdmin}
|
||||||
hasSources={sources.length > 0}
|
hasSources={sources.length > 0}
|
||||||
onEdit={() => setEditOpen(true)}
|
onEdit={() => setEditOpen(true)}
|
||||||
onFile={() => setFileOpen(true)}
|
onFile={() => setFileOpen(true)}
|
||||||
|
onChanged={reload}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -375,6 +397,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
open={fileOpen}
|
open={fileOpen}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
record={detail?.record ?? null}
|
record={detail?.record ?? null}
|
||||||
|
volumes={detail?.volumes ?? []}
|
||||||
providers={sources}
|
providers={sources}
|
||||||
onClose={() => setFileOpen(false)}
|
onClose={() => setFileOpen(false)}
|
||||||
onSaved={() => {
|
onSaved={() => {
|
||||||
@@ -382,6 +405,16 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
reload();
|
reload();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<RecordCatalogDialog
|
||||||
|
open={catalogOpen}
|
||||||
|
settings={settings}
|
||||||
|
catalog={catalog}
|
||||||
|
onClose={() => setCatalogOpen(false)}
|
||||||
|
onSaved={() => {
|
||||||
|
setCatalogOpen(false);
|
||||||
|
reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -389,17 +422,25 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
function RecordDetailPanel({
|
function RecordDetailPanel({
|
||||||
detail,
|
detail,
|
||||||
recordClass,
|
recordClass,
|
||||||
|
settings,
|
||||||
|
archiveProviders,
|
||||||
canWrite,
|
canWrite,
|
||||||
|
canAdmin,
|
||||||
hasSources,
|
hasSources,
|
||||||
onEdit,
|
onEdit,
|
||||||
onFile
|
onFile,
|
||||||
|
onChanged
|
||||||
}: {
|
}: {
|
||||||
detail: RecordDetail;
|
detail: RecordDetail;
|
||||||
recordClass?: RecordClass;
|
recordClass?: RecordClass;
|
||||||
|
settings: PlatformRouteContext["settings"];
|
||||||
|
archiveProviders: RecordArchiveProvider[];
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
|
canAdmin: boolean;
|
||||||
hasSources: boolean;
|
hasSources: boolean;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onFile: () => void;
|
onFile: () => void;
|
||||||
|
onChanged: () => void;
|
||||||
}) {
|
}) {
|
||||||
const record = detail.record;
|
const record = detail.record;
|
||||||
return (
|
return (
|
||||||
@@ -414,7 +455,7 @@ function RecordDetailPanel({
|
|||||||
<Pencil size={16} aria-hidden="true" />
|
<Pencil size={16} aria-hidden="true" />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</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" />
|
<FilePlus2 size={16} aria-hidden="true" />
|
||||||
File item
|
File item
|
||||||
</Button>
|
</Button>
|
||||||
@@ -434,6 +475,15 @@ function RecordDetailPanel({
|
|||||||
|
|
||||||
{record.description && <p className="records-description">{record.description}</p>}
|
{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">
|
<section className="records-detail-section" data-help-context-id="records.record-items">
|
||||||
<div className="records-section-heading">
|
<div className="records-section-heading">
|
||||||
<h2>Contents</h2>
|
<h2>Contents</h2>
|
||||||
@@ -549,7 +599,7 @@ function RecordDialog({
|
|||||||
file_plan_node_id: selectedClass.file_plan_node_id,
|
file_plan_node_id: selectedClass.file_plan_node_id,
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: description.trim() || null,
|
description: description.trim() || null,
|
||||||
state,
|
...(mode === "create" || record?.state === "planned" || record?.state === "open" ? { state } : {}),
|
||||||
classification: classification.trim() || null,
|
classification: classification.trim() || null,
|
||||||
purpose: purpose.trim(),
|
purpose: purpose.trim(),
|
||||||
recorded_at: new Date().toISOString(),
|
recorded_at: new Date().toISOString(),
|
||||||
@@ -601,10 +651,14 @@ function RecordDialog({
|
|||||||
<input value={recordNumber} onChange={(event) => setRecordNumber(event.target.value)} disabled={mode === "edit"} required />
|
<input value={recordNumber} onChange={(event) => setRecordNumber(event.target.value)} disabled={mode === "edit"} required />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="State" helpContextId="records.field.state">
|
<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")}>
|
<select value={state} onChange={(event) => setState(event.target.value as "planned" | "open")}>
|
||||||
<option value="planned">Planned</option>
|
<option value="planned">Planned</option>
|
||||||
<option value="open">Open</option>
|
<option value="open">Open</option>
|
||||||
</select>
|
</select>
|
||||||
|
)}
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Title" helpContextId="records.field.title">
|
<FormField label="Title" helpContextId="records.field.title">
|
||||||
<input value={title} onChange={(event) => setTitle(event.target.value)} required />
|
<input value={title} onChange={(event) => setTitle(event.target.value)} required />
|
||||||
@@ -635,6 +689,7 @@ function FilingDialog({
|
|||||||
open,
|
open,
|
||||||
settings,
|
settings,
|
||||||
record,
|
record,
|
||||||
|
volumes,
|
||||||
providers,
|
providers,
|
||||||
onClose,
|
onClose,
|
||||||
onSaved
|
onSaved
|
||||||
@@ -642,6 +697,7 @@ function FilingDialog({
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
settings: PlatformRouteContext["settings"];
|
settings: PlatformRouteContext["settings"];
|
||||||
record: RecordEntry | null;
|
record: RecordEntry | null;
|
||||||
|
volumes: RecordDetail["volumes"];
|
||||||
providers: RecordSourceProvider[];
|
providers: RecordSourceProvider[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
@@ -650,6 +706,7 @@ function FilingDialog({
|
|||||||
const [resourceType, setResourceType] = useState("");
|
const [resourceType, setResourceType] = useState("");
|
||||||
const [resourceId, setResourceId] = useState("");
|
const [resourceId, setResourceId] = useState("");
|
||||||
const [sourceRevision, setSourceRevision] = useState("");
|
const [sourceRevision, setSourceRevision] = useState("");
|
||||||
|
const [volumeId, setVolumeId] = useState("");
|
||||||
const [purpose, setPurpose] = useState("");
|
const [purpose, setPurpose] = useState("");
|
||||||
const [filingReason, setFilingReason] = useState("");
|
const [filingReason, setFilingReason] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -663,6 +720,7 @@ function FilingDialog({
|
|||||||
setResourceType(first?.resource_types[0] ?? "");
|
setResourceType(first?.resource_types[0] ?? "");
|
||||||
setResourceId("");
|
setResourceId("");
|
||||||
setSourceRevision("");
|
setSourceRevision("");
|
||||||
|
setVolumeId("");
|
||||||
setPurpose(record?.purpose ?? "");
|
setPurpose(record?.purpose ?? "");
|
||||||
setFilingReason("");
|
setFilingReason("");
|
||||||
setError("");
|
setError("");
|
||||||
@@ -690,6 +748,7 @@ function FilingDialog({
|
|||||||
purpose: purpose.trim(),
|
purpose: purpose.trim(),
|
||||||
filing_reason: filingReason.trim(),
|
filing_reason: filingReason.trim(),
|
||||||
relationship: "contains",
|
relationship: "contains",
|
||||||
|
volume_id: volumeId || null,
|
||||||
idempotency_key: randomId(),
|
idempotency_key: randomId(),
|
||||||
institutional_context: record.institutional_context,
|
institutional_context: record.institutional_context,
|
||||||
metadata: {}
|
metadata: {}
|
||||||
@@ -741,6 +800,12 @@ function FilingDialog({
|
|||||||
<FormField label="Exact source revision" documentation={RECORDS_FIELD_DOCUMENTATION.sourceRevision}>
|
<FormField label="Exact source revision" documentation={RECORDS_FIELD_DOCUMENTATION.sourceRevision}>
|
||||||
<input value={sourceRevision} onChange={(event) => setSourceRevision(event.target.value)} required />
|
<input value={sourceRevision} onChange={(event) => setSourceRevision(event.target.value)} required />
|
||||||
</FormField>
|
</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}>
|
<FormField label="Purpose" documentation={RECORDS_FIELD_DOCUMENTATION.purpose}>
|
||||||
<input value={purpose} onChange={(event) => setPurpose(event.target.value)} required />
|
<input value={purpose} onChange={(event) => setPurpose(event.target.value)} required />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|||||||
@@ -76,7 +76,80 @@ const en = {
|
|||||||
"Complete all required record fields.": "Complete all required record fields.",
|
"Complete all required record fields.": "Complete all required record fields.",
|
||||||
"The item is being filed.": "The item is being filed.",
|
"The item is being filed.": "The item is being filed.",
|
||||||
"Complete the exact source and filing reason.": "Complete the exact source and filing reason.",
|
"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;
|
} as const;
|
||||||
|
|
||||||
const de: Record<keyof typeof en, string> = {
|
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.",
|
"Complete all required record fields.": "Füllen Sie alle erforderlichen Aktenfelder aus.",
|
||||||
"The item is being filed.": "Das Objekt wird veraktet.",
|
"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.",
|
"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 };
|
export const generatedTranslations: PlatformTranslations = { en, de };
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const recordsModule: PlatformWebModule = {
|
|||||||
"reporting",
|
"reporting",
|
||||||
"dms",
|
"dms",
|
||||||
"policy",
|
"policy",
|
||||||
|
"approvals",
|
||||||
"audit",
|
"audit",
|
||||||
"search"
|
"search"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -290,6 +290,106 @@
|
|||||||
line-height: 1.55;
|
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 {
|
.records-detail-section {
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
}
|
}
|
||||||
@@ -457,6 +557,10 @@
|
|||||||
.records-facts {
|
.records-facts {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.records-lifecycle-evidence > dl {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
@@ -503,6 +607,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.records-facts,
|
.records-facts,
|
||||||
|
.records-lifecycle-evidence > dl,
|
||||||
|
.records-recovery-result dl,
|
||||||
.records-access-explanation dl,
|
.records-access-explanation dl,
|
||||||
.records-dialog-form {
|
.records-dialog-form {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
Reference in New Issue
Block a user