diff --git a/docs/CONCEPT.md b/docs/CONCEPT.md index b53e7ba..cb50831 100644 --- a/docs/CONCEPT.md +++ b/docs/CONCEPT.md @@ -166,6 +166,13 @@ correction, revocation, or remedy semantics. Evidence links should store only stable module/resource references and display metadata snapshots. The owning module remains responsible for the real object. +When Records is enabled, `records.source.cases` resolves one exact immutable +case revision for eAkte filing. Cases rechecks current tenant, scope, and +object-level access, then returns the case number, lifecycle state, represented +valid interval, recorded time, canonical snapshot digest, and launch link. +Records owns the filing decision and chronology; Cases remains authoritative +for the case and its revision history. + ## WebUI Initial route contributions: diff --git a/src/govoplan_cases/backend/manifest.py b/src/govoplan_cases/backend/manifest.py index 65ab50b..7815468 100644 --- a/src/govoplan_cases/backend/manifest.py +++ b/src/govoplan_cases/backend/manifest.py @@ -9,6 +9,7 @@ from govoplan_core.core.module_guards import ( from govoplan_core.core.institutional import CAPABILITY_PARTY_RESOLVER from govoplan_core.core.modules import ( CapabilityDocumentation, + DocumentationCondition, DocumentationLink, DocumentationTopic, FrontendModule, @@ -46,6 +47,10 @@ from govoplan_cases.backend.service import ( CAPABILITY_CASES_REGISTRY, SqlCaseRegistry, ) +from govoplan_cases.backend.record_source import ( + CAPABILITY_RECORD_SOURCE_CASES, + create_cases_record_source, +) from govoplan_core.db.base import Base @@ -131,6 +136,7 @@ manifest = ModuleManifest( "decisions", "forms_runtime", "workflow_engine", + "records", ), optional_capabilities=(CAPABILITY_PARTY_RESOLVER,), permissions=( @@ -283,6 +289,7 @@ manifest = ModuleManifest( ModuleInterfaceProvider(name="cases.party_context", version="0.1.0"), ModuleInterfaceProvider(name="cases.registry", version="0.1.0"), ModuleInterfaceProvider(name="cases.service_launcher", version="0.1.0"), + ModuleInterfaceProvider(name=CAPABILITY_RECORD_SOURCE_CASES, version="1.0.0"), ), requires_interfaces=( ModuleInterfaceRequirement(name="services.definition", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True), @@ -294,6 +301,7 @@ manifest = ModuleManifest( CAPABILITY_CASES_PARTY_CONTEXT: _party_context, CAPABILITY_CASES_REGISTRY: _case_registry, CAPABILITY_CASES_SERVICE_LAUNCHER: _service_launcher, + CAPABILITY_RECORD_SOURCE_CASES: create_cases_record_source, }, capability_documentation={ CAPABILITY_CASES_SERVICE_INTAKE: CapabilityDocumentation( @@ -316,6 +324,11 @@ manifest = ModuleManifest( summary="Starts a replay-safe case from an exact available Service revision.", contract_version="0.1.0", ), + CAPABILITY_RECORD_SOURCE_CASES: CapabilityDocumentation( + label="Cases record source", + summary="Resolves currently authorized immutable case revisions for Records filing.", + contract_version="1.0.0", + ), }, migration_spec=MigrationSpec( module_id=MODULE_ID, @@ -347,6 +360,48 @@ manifest = ModuleManifest( resource_acl_providers=(CaseAclProvider(),), tenant_summary_providers=(_tenant_summary,), documentation=( + DocumentationTopic( + id="cases.workflow.file-exact-revision", + title="File an exact case revision into an eAkte", + summary="Preserve a reconstructable case snapshot in Records without moving case ownership.", + body=( + "When Records is enabled, Cases resolves an exact immutable case revision only after " + "current case permission and object-level access are checked. The filed reference includes " + "the case number, lifecycle state, represented valid interval, recorded time, and a SHA-256 " + "digest of the canonical snapshot. Cases remains authoritative for the case lifecycle." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("case_manager", "records_manager", "operator", "auditor"), + related_modules=("records",), + order=15, + conditions=( + DocumentationCondition( + required_modules=("cases", "records"), + required_scopes=("cases:case:read", "records:workspace:write"), + ), + ), + links=( + DocumentationLink(label="Cases", href="/cases", kind="runtime"), + DocumentationLink(label="Records", href="/records", kind="runtime"), + DocumentationLink(label="Cases concept", href="govoplan-cases/docs/CONCEPT.md", kind="repository"), + ), + metadata={ + "kind": "workflow", + "help_contexts": ["cases.detail", "records.action.file"], + "prerequisites": [ + "You currently may read the case and file items into the destination record.", + "The exact immutable case revision has been identified.", + ], + "steps": [ + "Open the case and identify the revision that represents the evidence state.", + "Choose the destination record and state the access purpose and filing reason.", + "Confirm filing; Cases rechecks current access and resolves the exact revision.", + "Verify the case revision and snapshot digest in the record chronology.", + ], + "outcome": "The eAkte preserves an exact case snapshot reference while Cases retains authority.", + }, + ), DocumentationTopic( id="cases.institutional-context", title="Case institutional context", diff --git a/src/govoplan_cases/backend/record_source.py b/src/govoplan_cases/backend/record_source.py new file mode 100644 index 0000000..166faa8 --- /dev/null +++ b/src/govoplan_cases/backend/record_source.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from collections.abc import Sequence +import hashlib +import json +from urllib.parse import quote + +from sqlalchemy.orm import Session + +from govoplan_core.core.records import ( + RecordContractError, + RecordSourceLocator, + RecordSourceReference, +) +from govoplan_cases.backend.db.models import CaseIdentity, CaseRecordRevision +from govoplan_cases.backend.service import can_access_case + + +CAPABILITY_RECORD_SOURCE_CASES = "records.source.cases" + + +class CasesRecordSource: + provider_id = "cases" + + def resource_types(self) -> Sequence[str]: + return ("case_revision",) + + def resolve( + self, + session: object, + principal: object, + *, + locator: RecordSourceLocator, + purpose: str, + ) -> RecordSourceReference: + if not isinstance(session, Session): + raise RecordContractError( + "Case record references require a database session." + ) + tenant_id = str(getattr(principal, "tenant_id", "") or "").strip() + if not tenant_id or locator.tenant_id != tenant_id: + raise RecordContractError("Case record references cannot cross tenants.") + if locator.source_module != "cases" or locator.resource_type != "case_revision": + raise RecordContractError("Unsupported Cases record source type.") + if not str(purpose or "").strip(): + raise RecordContractError("Case record references require a purpose.") + if not hasattr(principal, "has") or not ( + principal.has("cases:case:read") or principal.has("cases:case:admin") + ): + raise RecordContractError("Current Cases read permission is required.") + if not can_access_case( + session, principal, case_id=locator.resource_id, permission="read" + ): + raise RecordContractError("The current principal cannot read this case.") + try: + revision = int(locator.source_revision) + except ValueError as exc: + raise RecordContractError("Case source revisions must be numeric.") from exc + row = ( + session.query(CaseRecordRevision) + .filter( + CaseRecordRevision.tenant_id == tenant_id, + CaseRecordRevision.case_id == locator.resource_id, + CaseRecordRevision.revision == revision, + ) + .one_or_none() + ) + if row is None: + raise RecordContractError("The exact case revision does not exist.") + identity = ( + session.query(CaseIdentity) + .filter( + CaseIdentity.tenant_id == tenant_id, + CaseIdentity.case_id == locator.resource_id, + ) + .one_or_none() + ) + if identity is None: + raise RecordContractError("The case identity is unavailable.") + snapshot_json = json.dumps( + row.snapshot, + sort_keys=True, + separators=(",", ":"), + default=str, + ).encode("utf-8") + return RecordSourceReference( + locator=locator, + label=f"{identity.case_number} - {row.title}", + authority_mode="external_authoritative", + content_sha256=hashlib.sha256(snapshot_json).hexdigest(), + content_type="application/vnd.govoplan.case-revision+json", + size_bytes=len(snapshot_json), + valid_from=row.opened_at, + valid_to=row.closed_at, + recorded_at=row.recorded_at, + launch_url=f"/cases/{quote(row.case_id, safe='')}", + metadata={ + "case_number": identity.case_number, + "case_type_key": row.case_type_key, + "status_key": row.status_key, + "access_mode": row.access_mode, + "snapshot_sha256": hashlib.sha256(snapshot_json).hexdigest(), + }, + ) + + +def create_cases_record_source(_context: object) -> CasesRecordSource: + return CasesRecordSource() + + +__all__ = [ + "CAPABILITY_RECORD_SOURCE_CASES", + "CasesRecordSource", + "create_cases_record_source", +] diff --git a/tests/test_record_source.py b/tests/test_record_source.py new file mode 100644 index 0000000..aa051ee --- /dev/null +++ b/tests/test_record_source.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +import unittest +from unittest.mock import patch + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from govoplan_core.core.records import RecordContractError, RecordSourceLocator +from govoplan_cases.backend.db.models import CaseIdentity, CaseRecordRevision +from govoplan_cases.backend.record_source import CasesRecordSource + + +NOW = datetime(2026, 1, 6, 9, 0, tzinfo=UTC) + + +@dataclass +class Principal: + tenant_id: str = "tenant-1" + + def has(self, scope: str) -> bool: + return scope == "cases:case:read" + + +class CasesRecordSourceTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite+pysqlite:///:memory:") + CaseIdentity.__table__.create(self.engine) + CaseRecordRevision.__table__.create(self.engine) + self.session = Session(self.engine) + identity = CaseIdentity( + id="identity-1", + tenant_id="tenant-1", + case_id="case-1", + case_number="2026/C-1", + created_by="account-1", + ) + revision = CaseRecordRevision( + id="revision-1", + tenant_id="tenant-1", + case_id="case-1", + identity_id="identity-1", + revision=3, + case_type_key="permit", + status_key="review", + title="Permit application", + access_mode="restricted", + search_text="permit application", + opened_at=NOW, + recorded_at=NOW, + snapshot={ + "case_id": "case-1", + "revision": 3, + "title": "Permit application", + }, + ) + self.session.add_all((identity, revision)) + self.session.flush() + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def locator(self) -> RecordSourceLocator: + return RecordSourceLocator( + tenant_id="tenant-1", + source_module="cases", + resource_type="case_revision", + resource_id="case-1", + source_revision="3", + ) + + def test_resolves_exact_currently_authorized_case_revision(self) -> None: + with patch( + "govoplan_cases.backend.record_source.can_access_case", return_value=True + ): + result = CasesRecordSource().resolve( + self.session, + Principal(), + locator=self.locator(), + purpose="preserve decision basis", + ) + + self.assertEqual("2026/C-1 - Permit application", result.label) + self.assertEqual(64, len(result.content_sha256 or "")) + self.assertEqual("review", result.metadata["status_key"]) + + def test_object_access_is_rechecked_and_fails_closed(self) -> None: + with ( + patch( + "govoplan_cases.backend.record_source.can_access_case", + return_value=False, + ), + self.assertRaisesRegex(RecordContractError, "cannot read"), + ): + CasesRecordSource().resolve( + self.session, + Principal(), + locator=self.locator(), + purpose="preserve decision basis", + ) + + +if __name__ == "__main__": + unittest.main()