diff --git a/README.md b/README.md index 7a6d06f..ab75819 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docs/EAKTE_ARCHITECTURE.md b/docs/EAKTE_ARCHITECTURE.md index 8b946f4..edb4c6f 100644 --- a/docs/EAKTE_ARCHITECTURE.md +++ b/docs/EAKTE_ARCHITECTURE.md @@ -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. diff --git a/docs/RECORDS_DOMAIN_BOUNDARY.md b/docs/RECORDS_DOMAIN_BOUNDARY.md index 3d7bed6..bba8f6f 100644 --- a/docs/RECORDS_DOMAIN_BOUNDARY.md +++ b/docs/RECORDS_DOMAIN_BOUNDARY.md @@ -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 diff --git a/package.json b/package.json index 881cd80..f9a703e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pyproject.toml b/pyproject.toml index e02bb1c..5e990e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/govoplan_records/backend/manifest.py b/src/govoplan_records/backend/manifest.py index 506e8bc..006ccd3 100644 --- a/src/govoplan_records/backend/manifest.py +++ b/src/govoplan_records/backend/manifest.py @@ -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", + ], + }, + ), ) diff --git a/tests/fixtures/service_to_decision_journey.json b/tests/fixtures/service_to_decision_journey.json index e243b33..0998476 100644 --- a/tests/fixtures/service_to_decision_journey.json +++ b/tests/fixtures/service_to_decision_journey.json @@ -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" } } diff --git a/tests/test_records.py b/tests/test_records.py index 92c1648..04ea5d7 100644 --- a/tests/test_records.py +++ b/tests/test_records.py @@ -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: diff --git a/tests/test_reference_journey_ui.py b/tests/test_reference_journey_ui.py new file mode 100644 index 0000000..8e7656d --- /dev/null +++ b/tests/test_reference_journey_ui.py @@ -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")', + '; + source_metadata: Record; + filing_metadata: Record; }; export type RecordChronology = { @@ -147,10 +153,15 @@ export type RecordTransferPackage = { provider_id: string; profile: string; status: string; + authority_mode: string; + manifest: Record; manifest_sha256: string; + receipt: Record; receipt_sha256?: string | null; external_reference?: string | null; + recovery_operation_id?: string | null; simulated: boolean; + institutional_context: Record; recorded_at: string; }; diff --git a/webui/src/features/records/RecordLifecyclePanel.tsx b/webui/src/features/records/RecordLifecyclePanel.tsx index f24553a..2791c62 100644 --- a/webui/src/features/records/RecordLifecyclePanel.tsx +++ b/webui/src/features/records/RecordLifecyclePanel.tsx @@ -314,7 +314,7 @@ function LifecycleEvidence({ detail, onRelease, canWrite }: { detail: RecordDeta
Appraisal
{record.appraisal_state ? humanize(record.appraisal_state) : "Not appraised"}
{detail.volumes.length > 0 &&
Volumes{detail.volumes.map((volume) =>
{volume.sequence}. {volume.label}
)}
} - {detail.holds.length > 0 &&
Holds{detail.holds.map((hold) =>
{hold.authority} · {hold.reason}{hold.status === "active" && }
)}
} + {detail.holds.length > 0 &&
Holds{detail.holds.map((hold) =>
{hold.authority} · {hold.reason}{hold.policy_refs.length > 0 ? ` · policy ${hold.policy_refs.join(", ")}` : ""}{hold.status === "active" && }
)}
} {detail.dispositions.map((item) => )} {detail.transfer_packages.map((item) => )} @@ -322,11 +322,20 @@ function LifecycleEvidence({ detail, onRelease, canWrite }: { detail: RecordDeta } function DispositionEvidence({ item }: { item: RecordDisposition }) { - return
Disposition
{humanize(item.action)} · {item.reason}
{item.approval_request_id && Approval: {item.approval_request_id}}
; + return
Disposition
{humanize(item.action)} · {item.reason}
{item.policy_refs.length > 0 && Policy: {item.policy_refs.join(", ")}}{item.approval_request_id && Approval: {item.approval_request_id}}
; } function TransferEvidence({ item }: { item: RecordTransferPackage }) { - return
Transfer package
{item.profile} · {item.manifest_sha256.slice(0, 12)}
{item.simulated && Simulation only; no archival custody.}
; + const receiptMetadata = typeof item.receipt.metadata === "object" && item.receipt.metadata != null + ? item.receipt.metadata as Record + : {}; + 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
Transfer package
{item.profile} · {item.manifest_sha256.slice(0, 12)}
{custodySummary} Authority: {humanize(item.authority_mode)}.
; } function RecoveryResult({ value, loading }: { value: Record | null; loading: boolean }) { diff --git a/webui/src/features/records/RecordsPage.tsx b/webui/src/features/records/RecordsPage.tsx index 0971639..78e71d3 100644 --- a/webui/src/features/records/RecordsPage.tsx +++ b/webui/src/features/records/RecordsPage.tsx @@ -464,6 +464,7 @@ function RecordDetailPanel({ onChanged: () => void; }) { const record = detail.record; + const governanceContext = recordContextFacts(record.institutional_context); return (
@@ -496,6 +497,17 @@ function RecordDetailPanel({ {record.description &&

{record.description}

} + {governanceContext.length > 0 && ( +
+

Governance context

+
+ {governanceContext.map(([label, value]) => ( +
{label}
{value}
+ ))} +
+
+ )} + {formatDateTime(item.filed_at)}

{item.filing_reason}

+
+
Authority
{humanize(item.authority_mode)}
+
Relationship
{humanize(item.relationship)}
+
Purpose
{item.purpose}
+
Source recorded
{formatDateTime(item.source_recorded_at)}
+ {typeof item.source_metadata.evidence_role === "string" && ( +
Evidence role
{humanize(item.source_metadata.evidence_role)}
+ )} +
))} @@ -882,6 +903,27 @@ function formatBytes(value?: number | null): string { return `${(value / 1024 / 1024).toFixed(1)} MiB`; } +function recordContextFacts(context: Record): 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()); } diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index 3cb869e..ce456df 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -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 = { "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 = { "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 = { "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", diff --git a/webui/src/styles/records.css b/webui/src/styles/records.css index e618687..613f71b 100644 --- a/webui/src/styles/records.css +++ b/webui/src/styles/records.css @@ -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,