feat(records): complete eAkte reference journey

This commit is contained in:
2026-08-22 18:42:09 +02:00
parent 38f203a906
commit 04142a6bb5
15 changed files with 1020 additions and 36 deletions
+24 -9
View File
@@ -12,7 +12,8 @@ authority over their objects and bytes.
The first working vertical slice includes PostgreSQL/SQLite models and an
Alembic migration, tenant-scoped APIs, optimistic concurrency and idempotent
filing, temporal reads, search registration, a full-height Records workspace,
and provider-neutral filing from Files and Cases.
provider-neutral filing from Files, Cases, Forms Runtime, and Decisions, and a
governed close/retention/hold/appraisal/disposition/transfer lifecycle.
The `privacy.dsar.records` capability exports bounded record and lifecycle
metadata from an explicit record or authoritative source linkage. Canonical
@@ -48,24 +49,38 @@ Implemented optional integrations:
- files
- cases
- forms runtime
- decisions
- approvals
- audit
- search
Planned optional integrations include:
- forms runtime and decisions
- Campaigns, Postbox, and Reporting
- DMS/archive providers
- target-tested DMS/archive providers
- policy
- audit
- transparency
## Current Boundary
The current kernel supports planned and open records. Restricted per-record
grants, closure, retention calculation, holds, appraisal, disposition,
transfer, destruction, and external archive effects remain explicit later
work packages. Enabling Records does not trigger destructive or external
effects.
The current kernel supports planned/open records, exact filing, closure,
retention calculation, holds, appraisal, independent disposition approval,
archive-neutral packaging, recovery evidence, and a clearly marked transfer
simulation. The simulation never claims archival custody. Restricted
per-record grants, real target conformance, destructive execution, and real
archive effects remain explicit later work packages.
## Reference Journey
The executable resident-parking-permit fixture proves equivalent assisted and
authenticated digital eAkten. It files the exact form, attachment, case and
representation, decision, delivery, and correction revisions; preserves
authority, purpose, policy, chronology, and custody evidence; exercises hold
and independent approval; and verifies search, temporal reconstruction, and
source/package integrity after a SQLite backup/restore round trip. The Records
workspace keeps the selected `recordId` in the URL so an authorized actor can
resume the same record and inspect the complete evidence chain.
The detailed target and implementation sequence are documented in
[docs/EAKTE_ARCHITECTURE.md](docs/EAKTE_ARCHITECTURE.md).
+15 -7
View File
@@ -40,10 +40,14 @@ packages tracked by Records #2-#6:
- tenant administration for versioned file-plan nodes and record classes,
explicit volume management, and lifecycle controls in the eAkte workspace.
The remaining boundary is intentionally visible rather than implied: Records
#7 requires selection and target testing of an archive/xdomea endpoint and
conformance profile; #8 completes the cross-module reference journey and
production evidence. Restricted per-record access grants also remain a
The cross-module reference journey from Records #8 is executable: equivalent
assisted and authenticated digital permit records preserve exact form,
attachment, case/representation, decision, delivery, and correction evidence;
exercise closure, retention, hold, approval, and simulated transfer; and prove
search and temporal reconstruction after database backup/restore. The
remaining boundary is intentionally visible rather than implied: Records #7
requires selection and target testing of an archive/xdomea endpoint and
conformance profile. Restricted per-record access grants also remain a
dedicated access-policy slice. Destruction is represented only as an approved
pending state; no content deletion or real archive effect is currently
claimed.
@@ -234,6 +238,10 @@ available in an evidence/details view.
5. Add destruction/recovery, TR-ESOR provider integration, migration, and
signed reference-journey evidence.
The first reference package should file the digital and assisted variants of
the same service-to-decision journey into equivalent records and prove search,
historical reconstruction, hold, transfer, restore, and access explanation.
The first reference package files the digital and assisted variants of the
resident-parking-permit service-to-decision journey into equivalent records.
Its fixture and regression test prove search, historical reconstruction, hold,
independent approval, simulated transfer, restore, current access explanation,
source digest revalidation, and transfer-manifest integrity. Its UI contract
pins URL-based resume, exact-source filing presets, governance context,
evidence semantics, lifecycle actions, and recovery inspection.
+10 -6
View File
@@ -38,8 +38,8 @@ The native kernel currently provides:
- independent valid and recorded time with current/at/all temporal reads;
- tenant, purpose, actor/capacity, source authority, institutional context,
provenance, and idempotency fields;
- provider-neutral exact-source capabilities, initially implemented by Files
file versions and Cases revisions;
- provider-neutral exact-source capabilities implemented by Files versions,
Cases revisions, Forms Runtime submissions, and Decisions revisions;
- tenant APIs, search projection, uninstall/retirement guards, and a Records
workspace using shared WebUI controls.
- immutable close/reopen, retention, hold, appraisal, and independently
@@ -49,6 +49,9 @@ The native kernel currently provides:
- Core recovery-ledger adoption, atomic terminal evidence, Audit projection,
source and package restore diagnostics, catalog administration, volumes, and
lifecycle UI.
- an executable assisted/digital resident-parking-permit reference package
that proves equivalent filing semantics, URL resume, search, temporal
reconstruction, and evidence integrity after database backup/restore.
Restricted object grants remain a separate governed slice. A target-tested
archive adapter and any destructive effect remain deliberately unimplemented;
@@ -73,12 +76,13 @@ immutable retention evidence; the current record fact receives a
non-executable manual-review action. Actual correction, closure, appraisal,
hold, disposition, or transfer must use the governed eAkte lifecycle.
## First Implementation Slice
## Next Boundary Slice
Complete restricted access and one target-tested archive provider without
moving source-module ownership into Records. Extend the executable reference
journey across assisted service, decision, filing, hold, restore, search, and
archive simulation before claiming production maturity.
moving source-module ownership into Records. The executable reference journey
already covers assisted/digital intake, decision, filing, hold, restore,
search, and archive simulation; it is evidence for the native boundary, not a
claim of target archive conformance or transferred custody.
The complete native/external boundary, temporal and purpose-aware record model,
disposition lifecycle, German public-sector provider profiles, and staged
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/records",
"version": "0.1.19",
"version": "0.1.20",
"private": true,
"description": "GovOPlaN eAkte and institutional records module.",
"type": "module",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-records"
version = "0.1.19"
version = "0.1.20"
description = "GovOPlaN eAkte and institutional records module."
readme = "README.md"
requires-python = ">=3.12"
+72 -4
View File
@@ -52,7 +52,7 @@ from govoplan_records.backend.archive import (
MODULE_ID = "records"
MODULE_NAME = "Records"
MODULE_VERSION = "0.1.19"
MODULE_VERSION = "0.1.20"
READ_SCOPE = "records:workspace:read"
WRITE_SCOPE = "records:workspace:write"
ADMIN_SCOPE = "records:workspace:admin"
@@ -237,7 +237,8 @@ DOCUMENTATION = (
"volumes, filing decisions, and chronology. Files and other source modules continue to own "
"their content. Filing resolves and preserves an exact source revision only after the source "
"module confirms current access. The titlebar temporal selection changes valid and recorded "
"time while current authorization always remains in force."
"time while current authorization always remains in force. The selected record is preserved "
"as a recordId URL parameter so an authorized actor can resume the same evidence view."
),
layer="configured",
documentation_types=("admin", "user"),
@@ -288,7 +289,7 @@ DOCUMENTATION = (
DocumentationTopic(
id="records.filing",
title="Exact record filing",
summary="File immutable Files or Cases revisions through a provider-neutral capability.",
summary="File immutable Files, Cases, Forms Runtime, or Decisions revisions through a provider-neutral capability.",
body=(
"Every filing requires a record, purpose, filing reason, idempotency key, and exact source "
"revision. Records stores source identity, authority mode, digest and content metadata where "
@@ -299,7 +300,14 @@ DOCUMENTATION = (
documentation_types=("admin", "user"),
audience=("user", "records_manager", "operator", "module_admin", "auditor"),
order=110,
related_modules=("files", "cases", "policy", "audit"),
related_modules=(
"files",
"cases",
"forms_runtime",
"decisions",
"policy",
"audit",
),
links=(
DocumentationLink(
label="Records domain boundary",
@@ -389,6 +397,66 @@ DOCUMENTATION = (
],
},
),
DocumentationTopic(
id="records.reference-journey",
title="Assisted and digital eAkte reference journey",
summary="Reconstruct the resident-parking-permit evidence chain and verify recovery without implying archive custody.",
body=(
"Equivalent assisted and authenticated digital resident-parking-permit records preserve exact "
"form, attachment, case and representation, formal decision, delivery receipt, and correction "
"revisions. The record view explains each source version, authority mode, relationship, filing "
"purpose, institutional context, legal basis, retention policy, chronology, hold, independent "
"approval, and transfer result. The selected recordId remains in the URL for authorized resume. "
"Automated evidence restores a database backup, searches and temporally reconstructs both records, "
"and revalidates source digests and the archive-neutral manifest. A simulated receipt explicitly "
"states that custody was not transferred; real archive conformance remains target-specific."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "records_manager", "operator", "module_admin", "auditor"),
order=130,
related_modules=(
"forms_runtime",
"files",
"cases",
"decisions",
"approvals",
"search",
),
links=(
DocumentationLink(
label="eAkte architecture",
href="govoplan-records/docs/EAKTE_ARCHITECTURE.md",
kind="repository",
),
),
translations={
"de": {
"title": "Referenzablauf für assistierte und digitale eAkten",
"summary": "Die Nachweiskette des Anwohnerparkausweises rekonstruieren und die Wiederherstellung prüfen, ohne Archivverwahrung zu behaupten.",
"body": (
"Gleichwertige assistierte und authentifizierte digitale Akten zum Anwohnerparkausweis "
"bewahren exakte Revisionen von Formular, Anlage, Vorgang und Vertretung, förmlicher "
"Entscheidung, Zustellbeleg und Korrektur. Die Aktenansicht erläutert Quellversion, "
"Autoritätsmodus, Beziehung, Veraktungszweck, institutionellen Kontext, Rechtsgrundlage, "
"Aufbewahrungsrichtlinie, Chronologie, Sperre, unabhängige Freigabe und Übergabeergebnis. "
"Die ausgewählte recordId bleibt zum berechtigten Fortsetzen in der URL. Automatisierte "
"Nachweise stellen eine Datenbanksicherung wieder her, suchen und rekonstruieren beide "
"Akten temporal und prüfen Quellprüfsummen sowie das archivneutrale Manifest erneut. Ein "
"simulierter Beleg stellt ausdrücklich klar, dass keine Verwahrung übertragen wurde."
),
}
},
metadata={
"help_contexts": [
"records.governance-context",
"records.record-items",
"records.chronology",
"records.lifecycle",
"records.lifecycle.recovery",
],
},
),
)
+178 -3
View File
@@ -1,18 +1,193 @@
{
"id": "resident-parking-permit-assisted-record",
"description": "A completed assisted resident parking permit service is filed, closed, held, appraised, independently approved, and exercised through the archive simulation boundary.",
"description": "A completed assisted resident parking permit service is filed as an eAkte, closed, held, appraised, independently approved, restored from backup, and exercised through the archive simulation boundary.",
"record": {
"record_id": "record-1",
"record_number": "2026/0001",
"class_id": "class-permit"
"class_id": "class-permit",
"title": "Permit application Ada Example"
},
"institutional_context": {
"service_id": "resident-parking-permit",
"case_id": "case-parking-2026-0001",
"applicant_party_id": "party-ada-example",
"representative_party_id": "party-alex-example",
"representation_id": "representation-2026-0001",
"authority_id": "authority-mobility-berlin",
"responsible_function_id": "function-parking-permits",
"mandate_id": "mandate-parking-permits-2026",
"purpose": "decide the resident parking permit application",
"legal_basis": "StVG section 6 and StVO section 45",
"retention_policy_ref": "records-policy:parking-permit:v1"
},
"digital_equivalent": {
"record": {
"record_id": "record-digital",
"record_number": "2026/0002",
"class_id": "class-permit",
"title": "Permit application Dana Digital"
},
"institutional_context_overrides": {
"case_id": "case-parking-2026-0002",
"applicant_party_id": "party-dana-digital",
"representative_party_id": "party-dana-digital",
"representation_id": "self-representation-2026-0002"
},
"intake_source": {
"id": "digital-submission",
"source_module": "forms_runtime",
"resource_type": "form_submission_revision",
"resource_id": "submission-parking-2026-0002",
"source_revision": "revision-2-submitted",
"label": "Digital resident parking permit submission",
"authority_mode": "native_authoritative",
"content_type": "application/json",
"size_bytes": 3968,
"relationship": "initiates",
"purpose": "document the submitted application",
"filing_reason": "The authenticated digital submission initiated the administrative procedure.",
"launch_url": "/forms-runtime/submissions/submission-parking-2026-0002",
"metadata": {
"evidence_role": "application",
"channel": "digital",
"authenticated": true,
"form_definition_revision": "resident-parking-permit:v4"
}
}
},
"sources": [
{
"id": "assisted-submission",
"source_module": "forms_runtime",
"resource_type": "form_submission_revision",
"resource_id": "submission-parking-2026-0001",
"source_revision": "revision-3-submitted",
"label": "Assisted resident parking permit submission",
"authority_mode": "native_authoritative",
"content_type": "application/json",
"size_bytes": 4096,
"relationship": "initiates",
"purpose": "document the submitted application",
"filing_reason": "The assisted, read-back submission initiated the administrative procedure.",
"launch_url": "/forms-runtime/submissions/submission-parking-2026-0001",
"metadata": {
"evidence_role": "application",
"channel": "assisted",
"read_back_confirmed": true,
"form_definition_revision": "resident-parking-permit:v4"
}
},
{
"id": "application-attachment",
"source_module": "files",
"resource_type": "file_version",
"resource_id": "file-registration-certificate",
"source_revision": "version-2",
"label": "Vehicle registration certificate.pdf",
"authority_mode": "external_authoritative",
"content_type": "application/pdf",
"size_bytes": 245760,
"relationship": "supports",
"purpose": "document application eligibility",
"filing_reason": "The submitted certificate supports the applicant's vehicle eligibility.",
"launch_url": "/files?fileId=file-registration-certificate",
"metadata": {
"evidence_role": "application_attachment",
"malware_scan": "clean"
}
},
{
"id": "case-and-representation",
"source_module": "cases",
"resource_type": "case_revision",
"resource_id": "case-parking-2026-0001",
"source_revision": "revision-5-representation-verified",
"label": "Resident parking permit case with verified representation",
"authority_mode": "native_authoritative",
"content_type": "application/json",
"size_bytes": 8192,
"relationship": "governs",
"purpose": "document case ownership and representation",
"filing_reason": "The case revision identifies the applicant, representative, authority, and verified representation used for the decision.",
"launch_url": "/cases/case-parking-2026-0001",
"metadata": {
"evidence_role": "case_context",
"representation_id": "representation-2026-0001",
"representation_status": "verified"
}
},
{
"id": "formal-decision",
"source_module": "decisions",
"resource_type": "decision_revision",
"resource_id": "decision-parking-2026-0001",
"source_revision": "revision-2-issued",
"label": "Resident parking permit decision",
"authority_mode": "native_authoritative",
"content_type": "application/json",
"size_bytes": 6144,
"relationship": "decides",
"purpose": "document the formal administrative outcome",
"filing_reason": "The issued decision records the competent authority, legal basis, outcome, and reasons.",
"launch_url": "/decisions/decision-parking-2026-0001",
"metadata": {
"evidence_role": "formal_decision",
"outcome": "approved",
"legal_basis": "StVG section 6 and StVO section 45"
}
},
{
"id": "delivery-receipt",
"source_module": "files",
"resource_type": "file_version",
"resource_id": "file-postbox-delivery-receipt",
"source_revision": "version-1",
"label": "Trusted postbox delivery receipt.json",
"authority_mode": "external_authoritative",
"content_type": "application/json",
"size_bytes": 2048,
"relationship": "proves_delivery",
"purpose": "document delivery of the formal decision",
"filing_reason": "The immutable receipt proves delivery of the issued decision to the represented applicant's trusted postbox.",
"launch_url": "/files?fileId=file-postbox-delivery-receipt",
"metadata": {
"evidence_role": "delivery_receipt",
"decision_revision": "revision-2-issued",
"delivery_channel": "trusted_postbox",
"delivery_status": "delivered"
}
},
{
"id": "correction-evidence",
"source_module": "files",
"resource_type": "file_version",
"resource_id": "file-decision-correction-evidence",
"source_revision": "version-1",
"label": "Decision address correction evidence.json",
"authority_mode": "external_authoritative",
"content_type": "application/json",
"size_bytes": 2304,
"relationship": "corrects",
"purpose": "preserve correction chronology without replacing prior evidence",
"filing_reason": "The correction evidence links the corrected delivery address to the original decision and receipt while preserving both.",
"launch_url": "/files?fileId=file-decision-correction-evidence",
"metadata": {
"evidence_role": "correction",
"corrects_source_id": "delivery-receipt",
"previous_evidence_retained": true
}
}
],
"expected": {
"equivalent_record_count": 2,
"filed_item_count": 6,
"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
"custody_transferred": false,
"restored_search_query": "Ada Example"
}
}
+503 -1
View File
@@ -2,8 +2,11 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
import hashlib
import json
from pathlib import Path
import sqlite3
import tempfile
import unittest
from sqlalchemy import create_engine
@@ -73,6 +76,48 @@ class SourceProvider:
)
class ReferenceJourneySourceProvider:
def __init__(self, provider_id: str, resource_types: tuple[str, ...]) -> None:
self.provider_id = provider_id
self._resource_types = resource_types
def resource_types(self):
return self._resource_types
def resolve(self, session, principal, *, locator, purpose):
del session, purpose
if principal.tenant_id != locator.tenant_id:
raise ValueError("Source access denied.")
if locator.source_module != self.provider_id:
raise ValueError("The source module does not match this provider.")
if locator.resource_type not in self._resource_types:
raise ValueError("The source type is not supported by this provider.")
metadata = dict(locator.metadata)
reference = dict(metadata.get("reference") or {})
digest_input = ":".join(
(
locator.source_module,
locator.resource_type,
locator.resource_id,
locator.source_revision,
)
).encode("utf-8")
return RecordSourceReference(
locator=locator,
label=str(reference.get("label") or locator.resource_id),
authority_mode=str(
reference.get("authority_mode") or "linked_reference"
),
content_sha256=hashlib.sha256(digest_input).hexdigest(),
content_type=str(reference.get("content_type") or "application/json"),
size_bytes=int(reference.get("size_bytes") or 0),
valid_from=NOW,
recorded_at=NOW,
launch_url=str(reference.get("launch_url") or "") or None,
metadata=metadata,
)
class UnknownOutcomeArchiveProvider:
provider_id = "unknown_simulation"
@@ -131,6 +176,33 @@ class Registry:
}.get(name)
class ReferenceJourneyRegistry(Registry):
def __init__(self) -> None:
super().__init__()
self.source_providers_by_module = {
"files": ReferenceJourneySourceProvider("files", ("file_version",)),
"forms_runtime": ReferenceJourneySourceProvider(
"forms_runtime", ("form_submission_revision",)
),
"cases": ReferenceJourneySourceProvider("cases", ("case_revision",)),
"decisions": ReferenceJourneySourceProvider(
"decisions", ("decision_revision",)
),
}
def capability_names(self):
return (
*(f"records.source.{name}" for name in self.source_providers_by_module),
"records.archive.simulation",
"records.archive.unknown_simulation",
)
def tenant_capability(self, name, session, *, tenant_id):
if name.startswith("records.source.") and tenant_id == "tenant-1":
return self.source_providers_by_module.get(name.removeprefix("records.source."))
return super().tenant_capability(name, session, tenant_id=tenant_id)
class ApprovalProvider:
def __init__(self) -> None:
self.approved = False
@@ -231,7 +303,12 @@ class RecordsTests(unittest.TestCase):
"file_plan_node_id": "plan-permits",
"key": "permit.application",
"label": "Permit application",
"allowed_source_types": ["files:file_version"],
"allowed_source_types": [
"files:file_version",
"forms_runtime:form_submission_revision",
"cases:case_revision",
"decisions:decision_revision",
],
"retention_period_days": 3650,
"access_mode": "tenant",
"recorded_at": NOW,
@@ -727,6 +804,431 @@ class RecordsTests(unittest.TestCase):
self.assertTrue(recovery["healthy"])
self.assertTrue(recovery["package_checks"][0]["manifest_verified"])
def test_assisted_service_reference_journey_survives_backup_and_restore(
self,
) -> None:
fixture = json.loads(
(
Path(__file__).parent / "fixtures/service_to_decision_journey.json"
).read_text(encoding="utf-8")
)
expected = fixture["expected"]
context = fixture["institutional_context"]
self.records = SqlRecordRegistry(ReferenceJourneyRegistry())
def create_journey_record(
record_data: dict[str, object],
record_context: dict[str, object],
*,
idempotency_suffix: str,
channel: str,
) -> dict[str, object]:
return self.records.create_record(
self.session,
self.principal,
payload={
**record_data,
"file_plan_node_id": "plan-permits",
"description": fixture["description"],
"state": "open",
"source_authority_mode": "native_authoritative",
"access_mode": "tenant",
"purpose": record_context["purpose"],
"responsible_unit_id": "unit-mobility-services",
"responsible_function_id": record_context[
"responsible_function_id"
],
"institutional_context": {**record_context, "channel": channel},
"recorded_at": NOW + timedelta(minutes=1),
"valid_from": NOW,
"change_reason": f"The {channel} application was accepted for processing.",
"idempotency_key": f"reference-record-create-{idempotency_suffix}",
},
)
def filing_request(
record_id: str,
source: dict[str, object],
record_context: dict[str, object],
*,
idempotency_suffix: str = "",
) -> RecordFilingRequest:
metadata = dict(source["metadata"])
return RecordFilingRequest(
tenant_id="tenant-1",
record_id=record_id,
source=RecordSourceLocator(
tenant_id="tenant-1",
source_module=str(source["source_module"]),
resource_type=str(source["resource_type"]),
resource_id=str(source["resource_id"]),
source_revision=str(source["source_revision"]),
metadata={
"reference": {
"label": source["label"],
"authority_mode": source["authority_mode"],
"content_type": source["content_type"],
"size_bytes": source["size_bytes"],
"launch_url": source["launch_url"],
},
**metadata,
},
),
purpose=str(source["purpose"]),
filing_reason=str(source["filing_reason"]),
relationship=str(source["relationship"]),
institutional_context={
**record_context,
"evidence_role": metadata["evidence_role"],
},
metadata={"fixture_source_id": source["id"]},
idempotency_key=(
f"reference-file-{source['id']}{idempotency_suffix}"
),
)
record = create_journey_record(
fixture["record"], context, idempotency_suffix="assisted", channel="assisted"
)
self.assertEqual(fixture["record"]["title"], record["title"])
filed_results = []
for source in fixture["sources"]:
filed_results.append(
self.records.file(
self.session,
self.principal,
request=filing_request(str(record["record_id"]), source, context),
)
)
replay = self.records.file(
self.session,
self.principal,
request=filing_request(
str(record["record_id"]), fixture["sources"][0], context
),
)
self.assertTrue(replay.replayed)
self.assertEqual(filed_results[0].item_id, replay.item_id)
detail = self.records.get_record(
self.session, self.principal, record_id=str(record["record_id"])
)
self.assertEqual(expected["filed_item_count"], len(detail["items"]))
self.assertEqual(
[source["source_revision"] for source in fixture["sources"]],
[item["source"]["source_revision"] for item in detail["items"]],
)
self.assertEqual(
[source["relationship"] for source in fixture["sources"]],
[item["relationship"] for item in detail["items"]],
)
self.assertEqual(
{
"application",
"application_attachment",
"case_context",
"formal_decision",
"delivery_receipt",
"correction",
},
{item["source_metadata"]["evidence_role"] for item in detail["items"]},
)
digital_fixture = fixture["digital_equivalent"]
digital_context = {
**context,
**digital_fixture["institutional_context_overrides"],
}
digital_record = create_journey_record(
digital_fixture["record"],
digital_context,
idempotency_suffix="digital",
channel="digital",
)
digital_sources = [digital_fixture["intake_source"]]
for source in fixture["sources"][1:]:
digital_sources.append(
{
**source,
"id": f"digital-{source['id']}",
"resource_id": f"{source['resource_id']}-digital",
"label": f"Digital equivalent · {source['label']}",
}
)
for source in digital_sources:
self.records.file(
self.session,
self.principal,
request=filing_request(
str(digital_record["record_id"]),
source,
digital_context,
idempotency_suffix="-digital",
),
)
digital_detail = self.records.get_record(
self.session,
self.principal,
record_id=str(digital_record["record_id"]),
)
def equivalence_fields(item):
return (
item["source"]["source_module"],
item["source"]["resource_type"],
item["relationship"],
item["source_metadata"]["evidence_role"],
)
self.assertEqual(
[equivalence_fields(item) for item in detail["items"]],
[equivalence_fields(item) for item in digital_detail["items"]],
)
all_records, total_records = self.records.list_records(
self.session, self.principal
)
self.assertEqual(expected["equivalent_record_count"], total_records)
self.assertEqual(
{fixture["record"]["record_id"], digital_fixture["record"]["record_id"]},
{item["record_id"] for item in all_records},
)
lifecycle_start = datetime.now(UTC).replace(microsecond=0) + timedelta(
minutes=10
)
closed = self.records.close_record(
self.session,
self.principal,
record_id=str(record["record_id"]),
payload={
"expected_revision": 1,
"purpose": "close completed resident parking permit record",
"reason": "Decision, delivery, and correction evidence are complete.",
"recorded_at": lifecycle_start,
"idempotency_key": "reference-close",
},
)
self.assertEqual(expected["closed_state"], closed["state"])
appraised = self.records.appraise_record(
self.session,
self.principal,
record_id=str(record["record_id"]),
payload={
"expected_revision": 2,
"outcome": "transfer",
"purpose": "appraise completed resident parking permit record",
"reason": "Offer the complete record after governed review.",
"policy_refs": [context["retention_policy_ref"]],
"override_retention_not_due": True,
"recorded_at": lifecycle_start + timedelta(minutes=1),
"idempotency_key": "reference-appraise",
},
)
self.assertEqual(expected["appraised_state"], appraised["state"])
hold = self.records.apply_hold(
self.session,
self.principal,
record_id=str(record["record_id"]),
payload={
"expected_record_revision": 3,
"reason": "Preserve the record while a correction is reviewed.",
"authority": "Mobility authority review 2026-17",
"purpose": "preserve correction evidence",
"policy_refs": [context["retention_policy_ref"]],
"institutional_context": context,
"recorded_at": lifecycle_start + timedelta(minutes=2),
"idempotency_key": "reference-hold",
},
)
with self.assertRaisesRegex(RecordConflictError, "active record hold"):
self.records.propose_disposition(
self.session,
self.principal,
record_id=str(record["record_id"]),
payload={
"expected_record_revision": 3,
"action": "transfer",
"reason": "Attempt transfer while evidence is held.",
"purpose": "prove hold enforcement",
"recorded_at": lifecycle_start + timedelta(minutes=3),
"idempotency_key": "reference-disposition-blocked",
},
)
self.records.release_hold(
self.session,
self.principal,
record_id=str(record["record_id"]),
hold_id=str(hold["hold_id"]),
payload={
"expected_hold_revision": 1,
"reason": "Correction review completed with prior evidence preserved.",
"purpose": "resume governed disposition",
"recorded_at": lifecycle_start + timedelta(minutes=4),
"idempotency_key": "reference-hold-release",
},
)
disposition = self.records.propose_disposition(
self.session,
self.principal,
record_id=str(record["record_id"]),
payload={
"disposition_id": "reference-disposition",
"expected_record_revision": 3,
"action": "transfer",
"reason": "The complete record is ready for an independently reviewed offer.",
"purpose": "prepare governed archive offer",
"policy_refs": [context["retention_policy_ref"]],
"institutional_context": context,
"recorded_at": lifecycle_start + timedelta(minutes=5),
"idempotency_key": "reference-disposition",
},
)
self.records.registry.approvals.approved = True
finalized = self.records.finalize_disposition(
self.session,
self.principal,
record_id=str(record["record_id"]),
disposition_id=str(disposition["disposition_id"]),
payload={
"expected_disposition_revision": 1,
"purpose": "record independent disposition approval",
"recorded_at": lifecycle_start + timedelta(minutes=6),
"idempotency_key": "reference-disposition-finalize",
},
)
self.assertEqual(expected["approved_state"], finalized["record"]["state"])
package = self.records.prepare_transfer_package(
self.session,
self.principal,
record_id=str(record["record_id"]),
payload={
"package_id": "reference-transfer-package",
"disposition_id": str(disposition["disposition_id"]),
"expected_record_revision": 4,
"provider_id": "simulation",
"profile": "govoplan-simulation-v1",
"purpose": "validate archive transfer boundary",
"recorded_at": lifecycle_start + timedelta(minutes=7),
"idempotency_key": "reference-transfer-prepare",
},
)
receipt = self.records.dispatch_transfer_package(
self.session,
self.principal,
record_id=str(record["record_id"]),
package_id=str(package["package_id"]),
payload={
"expected_package_revision": 1,
"purpose": "validate archive receipt handling",
"recorded_at": lifecycle_start + timedelta(minutes=8),
"idempotency_key": "reference-transfer-dispatch",
},
)
self.assertEqual(expected["transfer_status"], receipt["status"])
self.assertEqual(
expected["custody_transferred"],
receipt["receipt"]["metadata"]["custody_transferred"],
)
self.session.commit()
with tempfile.TemporaryDirectory(prefix="govoplan-records-restore-") as temp_dir:
backup_path = Path(temp_dir) / "restored-records.sqlite3"
source_connection = self.engine.raw_connection()
try:
with sqlite3.connect(backup_path) as backup_connection:
source_connection.driver_connection.backup(backup_connection)
finally:
source_connection.close()
restored_engine = create_engine(f"sqlite+pysqlite:///{backup_path}")
restored_session = Session(restored_engine)
try:
restored_records = SqlRecordRegistry(ReferenceJourneyRegistry())
matches, total = restored_records.list_records(
restored_session,
self.principal,
query=expected["restored_search_query"],
)
self.assertEqual(1, total)
self.assertEqual(record["record_id"], matches[0]["record_id"])
restored = restored_records.get_record(
restored_session,
self.principal,
record_id=str(record["record_id"]),
)
self.assertEqual(expected["filed_item_count"], len(restored["items"]))
self.assertEqual(
{**context, "channel": "assisted"},
restored["record"]["institutional_context"],
)
self.assertEqual("released", restored["holds"][0]["status"])
self.assertEqual(
expected["disposition_status"],
restored["dispositions"][0]["status"],
)
self.assertEqual(
[context["retention_policy_ref"]],
restored["dispositions"][0]["policy_refs"],
)
self.assertEqual(
expected["transfer_status"],
restored["transfer_packages"][0]["status"],
)
self.assertTrue(restored["transfer_packages"][0]["simulated"])
self.assertEqual(
expected["filed_item_count"],
sum(
entry["event_type"] == "record.item_filed"
for entry in restored["chronology"]
),
)
recovery = restored_records.recovery_status(
restored_session,
self.principal,
record_id=str(record["record_id"]),
)
self.assertTrue(recovery["healthy"])
self.assertEqual(
expected["filed_item_count"], len(recovery["source_checks"])
)
self.assertTrue(
all(
check["status"] == "verified"
for check in recovery["source_checks"]
)
)
self.assertTrue(
all(
check["manifest_verified"]
for check in recovery["package_checks"]
)
)
token = bind_temporal_data_context(
TemporalDataContext(
validity_mode="at",
valid_at=lifecycle_start - timedelta(minutes=1),
recorded_at=lifecycle_start - timedelta(minutes=1),
)
)
try:
historical = restored_records.get_record(
restored_session,
self.principal,
record_id=str(record["record_id"]),
)
self.assertEqual("open", historical["record"]["state"])
self.assertEqual(
expected["filed_item_count"], len(historical["items"])
)
self.assertEqual([], historical["transfer_packages"])
finally:
reset_temporal_data_context(token)
finally:
restored_session.close()
restored_engine.dispose()
def test_disposition_waits_for_optional_approvals_and_can_be_withdrawn(
self,
) -> None:
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[1]
class ReferenceJourneyUiTests(unittest.TestCase):
def test_actor_can_resume_and_reconstruct_the_record_journey(self) -> None:
page = (ROOT / "webui/src/features/records/RecordsPage.tsx").read_text(
encoding="utf-8"
)
lifecycle = (
ROOT / "webui/src/features/records/RecordLifecyclePanel.tsx"
).read_text(encoding="utf-8")
api = (ROOT / "webui/src/api/records.ts").read_text(encoding="utf-8")
for marker in (
'searchParams.get("recordId")',
'next.set("recordId", selectedRecordId)',
"filingPresetFrom(searchParams)",
'searchParams.get("sourceRevision")',
'<WorkspaceActionBar',
"refreshable",
'reloadAction={{ onReload: reload',
'data-help-context-id="records.governance-context"',
'data-help-context-id="records.record-items"',
'data-help-context-id="records.chronology"',
"item.source.source_revision",
"item.authority_mode",
"item.relationship",
"item.purpose",
"item.source_metadata.evidence_role",
"detail.access_explanation.current_authorization",
):
self.assertIn(marker, page)
for action in (
'openAction("close")',
'openAction("reopen")',
'openAction("appraise")',
'openAction("hold")',
'openAction("release-hold", holdId)',
'openAction("disposition")',
'openAction("finalize")',
'openAction("prepare-transfer")',
'openAction("dispatch-transfer")',
"openRecovery",
"Release all active holds first.",
"Simulation only",
"custody",
"item.policy_refs",
):
self.assertIn(action, lifecycle)
for endpoint in (
'"close"',
'"reopen"',
'"appraise"',
'"holds"',
'"dispositions"',
'"transfer-packages"',
"/recovery`",
):
self.assertIn(endpoint, api)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/records-webui",
"version": "0.1.19",
"version": "0.1.20",
"private": true,
"type": "module",
"main": "src/index.ts",
+11
View File
@@ -89,9 +89,15 @@ export type RecordItem = {
content_sha256?: string | null;
content_type?: string | null;
size_bytes?: number | null;
source_valid_from?: string | null;
source_valid_to?: string | null;
source_recorded_at?: string | null;
launch_url?: string | null;
filed_at: string;
filed_by?: string | null;
institutional_context: Record<string, unknown>;
source_metadata: Record<string, unknown>;
filing_metadata: Record<string, unknown>;
};
export type RecordChronology = {
@@ -147,10 +153,15 @@ export type RecordTransferPackage = {
provider_id: string;
profile: string;
status: string;
authority_mode: string;
manifest: Record<string, unknown>;
manifest_sha256: string;
receipt: Record<string, unknown>;
receipt_sha256?: string | null;
external_reference?: string | null;
recovery_operation_id?: string | null;
simulated: boolean;
institutional_context: Record<string, unknown>;
recorded_at: string;
};
@@ -314,7 +314,7 @@ function LifecycleEvidence({ detail, onRelease, canWrite }: { detail: RecordDeta
<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.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}{hold.policy_refs.length > 0 ? ` · policy ${hold.policy_refs.join(", ")}` : ""}</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>
@@ -322,11 +322,20 @@ function LifecycleEvidence({ detail, onRelease, canWrite }: { detail: RecordDeta
}
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>;
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.policy_refs.length > 0 && <small>Policy: {item.policy_refs.join(", ")}</small>}{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>;
const receiptMetadata = typeof item.receipt.metadata === "object" && item.receipt.metadata != null
? item.receipt.metadata as Record<string, unknown>
: {};
const custodyTransferred = receiptMetadata.custody_transferred === true;
const custodySummary = item.simulated
? "Simulation only; no archival custody."
: custodyTransferred
? "Archive custody was transferred."
: "Archive custody was not transferred.";
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><small>{custodySummary} Authority: {humanize(item.authority_mode)}.</small></div>;
}
function RecoveryResult({ value, loading }: { value: Record<string, unknown> | null; loading: boolean }) {
@@ -464,6 +464,7 @@ function RecordDetailPanel({
onChanged: () => void;
}) {
const record = detail.record;
const governanceContext = recordContextFacts(record.institutional_context);
return (
<PageScrollViewport className="records-detail-scroll">
<div className="records-detail-header">
@@ -496,6 +497,17 @@ function RecordDetailPanel({
{record.description && <p className="records-description">{record.description}</p>}
{governanceContext.length > 0 && (
<section className="records-detail-section" data-help-context-id="records.governance-context">
<div className="records-section-heading"><h2>Governance context</h2></div>
<dl className="records-context-facts">
{governanceContext.map(([label, value]) => (
<div key={label}><dt>{label}</dt><dd>{value}</dd></div>
))}
</dl>
</section>
)}
<RecordLifecyclePanel
detail={detail}
archiveProviders={archiveProviders}
@@ -525,6 +537,15 @@ function RecordDetailPanel({
<time>{formatDateTime(item.filed_at)}</time>
</div>
<p>{item.filing_reason}</p>
<dl className="records-item-evidence">
<div><dt>Authority</dt><dd>{humanize(item.authority_mode)}</dd></div>
<div><dt>Relationship</dt><dd>{humanize(item.relationship)}</dd></div>
<div><dt>Purpose</dt><dd>{item.purpose}</dd></div>
<div><dt>Source recorded</dt><dd>{formatDateTime(item.source_recorded_at)}</dd></div>
{typeof item.source_metadata.evidence_role === "string" && (
<div><dt>Evidence role</dt><dd>{humanize(item.source_metadata.evidence_role)}</dd></div>
)}
</dl>
</div>
))}
</div>
@@ -882,6 +903,27 @@ function formatBytes(value?: number | null): string {
return `${(value / 1024 / 1024).toFixed(1)} MiB`;
}
function recordContextFacts(context: Record<string, unknown>): Array<[string, string]> {
const fields: Array<[string, string]> = [
["Service", "service_id"],
["Case", "case_id"],
["Applicant", "applicant_party_id"],
["Representative", "representative_party_id"],
["Representation", "representation_id"],
["Authority", "authority_id"],
["Responsible function", "responsible_function_id"],
["Mandate", "mandate_id"],
["Legal basis", "legal_basis"],
["Retention policy", "retention_policy_ref"]
];
return fields.flatMap(([label, key]) => {
const value = context[key];
if (typeof value === "string" && value.trim()) return [[label, value]];
if (Array.isArray(value) && value.length > 0) return [[label, value.map(String).join(", ")]];
return [];
});
}
function humanize(value: string): string {
return value.replace(/[_:.\-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
+30
View File
@@ -41,6 +41,16 @@ const en = {
"Retention input": "Retention input",
"Not classified": "Not classified",
"Not configured": "Not configured",
"Governance context": "Governance context",
"Service": "Service",
"Case": "Case",
"Applicant": "Applicant",
"Representative": "Representative",
"Representation": "Representation",
"Responsible function": "Responsible function",
"Mandate": "Mandate",
"Legal basis": "Legal basis",
"Retention policy": "Retention policy",
"Contents": "Contents",
"No items have been filed in this temporal view.": "No items have been filed in this temporal view.",
"Chronology": "Chronology",
@@ -66,6 +76,9 @@ const en = {
"Source object ID": "Source object ID",
"Exact source revision": "Exact source revision",
"Filing reason": "Filing reason",
"Relationship": "Relationship",
"Source recorded": "Source recorded",
"Evidence role": "Evidence role",
"Not set": "Not set",
"Size unavailable": "Size unavailable",
"Your account may view records but may not create them.": "Your account may view records but may not create them.",
@@ -121,6 +134,8 @@ const en = {
"Disposition": "Disposition",
"Transfer package": "Transfer package",
"Simulation only; no archival custody.": "Simulation only; no archival custody.",
"Archive custody was transferred.": "Archive custody was transferred.",
"Archive custody was not transferred.": "Archive custody was not transferred.",
"Volume label": "Volume label",
"Authority": "Authority",
"Explicit retention trigger": "Explicit retention trigger",
@@ -194,6 +209,16 @@ const de: Record<keyof typeof en, string> = {
"Retention input": "Aufbewahrungsvorgabe",
"Not classified": "Nicht klassifiziert",
"Not configured": "Nicht konfiguriert",
"Governance context": "Governance-Kontext",
"Service": "Leistung",
"Case": "Vorgang",
"Applicant": "Antragstellende Person",
"Representative": "Vertretende Person",
"Representation": "Vertretung",
"Responsible function": "Zuständige Funktion",
"Mandate": "Mandat",
"Legal basis": "Rechtsgrundlage",
"Retention policy": "Aufbewahrungsrichtlinie",
"Contents": "Inhalt",
"No items have been filed in this temporal view.": "In dieser temporalen Ansicht sind keine Objekte veraktet.",
"Chronology": "Chronologie",
@@ -219,6 +244,9 @@ const de: Record<keyof typeof en, string> = {
"Source object ID": "ID des Quellobjekts",
"Exact source revision": "Exakte Quellrevision",
"Filing reason": "Veraktungsbegründung",
"Relationship": "Beziehung",
"Source recorded": "Quelle erfasst am",
"Evidence role": "Nachweisrolle",
"Not set": "Nicht gesetzt",
"Size unavailable": "Größe nicht verfügbar",
"Your account may view records but may not create them.": "Ihr Konto darf Akten einsehen, aber nicht anlegen.",
@@ -274,6 +302,8 @@ const de: Record<keyof typeof en, string> = {
"Disposition": "Aussonderung",
"Transfer package": "Übergabepaket",
"Simulation only; no archival custody.": "Nur Simulation; keine Archivverwahrung.",
"Archive custody was transferred.": "Die Archivverwahrung wurde übertragen.",
"Archive custody was not transferred.": "Die Archivverwahrung wurde nicht übertragen.",
"Volume label": "Bandbezeichnung",
"Authority": "Anordnende Stelle",
"Explicit retention trigger": "Expliziter Aufbewahrungsbeginn",
+49
View File
@@ -253,6 +253,42 @@
line-height: 1.55;
}
.records-context-facts,
.records-item-evidence {
display: grid;
margin: 0;
}
.records-context-facts {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--line);
}
.records-context-facts > div {
min-width: 0;
padding: 9px 10px;
background: var(--surface);
}
.records-context-facts dt,
.records-item-evidence dt {
color: var(--muted);
font-size: 12px;
}
.records-context-facts dd,
.records-item-evidence dd {
overflow-wrap: anywhere;
margin: 3px 0 0;
color: var(--text-strong);
font-size: var(--font-size-sm);
font-weight: 600;
}
.records-lifecycle-actions {
display: flex;
flex-wrap: wrap;
@@ -424,6 +460,17 @@
font-size: var(--font-size-sm);
}
.records-item-evidence {
grid-column: 1 / -1;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
padding-top: 4px;
}
.records-item-evidence > div {
min-width: 0;
}
.records-chronology > div {
position: relative;
display: grid;
@@ -564,6 +611,8 @@
}
.records-facts,
.records-context-facts,
.records-item-evidence,
.records-lifecycle-evidence > dl,
.records-recovery-result dl,
.records-access-explanation dl,