feat(cases): expose exact revisions for records filing

This commit is contained in:
2026-08-06 01:43:32 +02:00
parent cfe2389e5a
commit 2f030e56a9
4 changed files with 284 additions and 0 deletions
+7
View File
@@ -166,6 +166,13 @@ correction, revocation, or remedy semantics.
Evidence links should store only stable module/resource references and display Evidence links should store only stable module/resource references and display
metadata snapshots. The owning module remains responsible for the real object. 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 ## WebUI
Initial route contributions: Initial route contributions:
+55
View File
@@ -9,6 +9,7 @@ from govoplan_core.core.module_guards import (
from govoplan_core.core.institutional import CAPABILITY_PARTY_RESOLVER from govoplan_core.core.institutional import CAPABILITY_PARTY_RESOLVER
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
CapabilityDocumentation, CapabilityDocumentation,
DocumentationCondition,
DocumentationLink, DocumentationLink,
DocumentationTopic, DocumentationTopic,
FrontendModule, FrontendModule,
@@ -46,6 +47,10 @@ from govoplan_cases.backend.service import (
CAPABILITY_CASES_REGISTRY, CAPABILITY_CASES_REGISTRY,
SqlCaseRegistry, SqlCaseRegistry,
) )
from govoplan_cases.backend.record_source import (
CAPABILITY_RECORD_SOURCE_CASES,
create_cases_record_source,
)
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
@@ -131,6 +136,7 @@ manifest = ModuleManifest(
"decisions", "decisions",
"forms_runtime", "forms_runtime",
"workflow_engine", "workflow_engine",
"records",
), ),
optional_capabilities=(CAPABILITY_PARTY_RESOLVER,), optional_capabilities=(CAPABILITY_PARTY_RESOLVER,),
permissions=( permissions=(
@@ -283,6 +289,7 @@ manifest = ModuleManifest(
ModuleInterfaceProvider(name="cases.party_context", version="0.1.0"), ModuleInterfaceProvider(name="cases.party_context", version="0.1.0"),
ModuleInterfaceProvider(name="cases.registry", version="0.1.0"), ModuleInterfaceProvider(name="cases.registry", version="0.1.0"),
ModuleInterfaceProvider(name="cases.service_launcher", 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=( requires_interfaces=(
ModuleInterfaceRequirement(name="services.definition", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True), 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_PARTY_CONTEXT: _party_context,
CAPABILITY_CASES_REGISTRY: _case_registry, CAPABILITY_CASES_REGISTRY: _case_registry,
CAPABILITY_CASES_SERVICE_LAUNCHER: _service_launcher, CAPABILITY_CASES_SERVICE_LAUNCHER: _service_launcher,
CAPABILITY_RECORD_SOURCE_CASES: create_cases_record_source,
}, },
capability_documentation={ capability_documentation={
CAPABILITY_CASES_SERVICE_INTAKE: CapabilityDocumentation( CAPABILITY_CASES_SERVICE_INTAKE: CapabilityDocumentation(
@@ -316,6 +324,11 @@ manifest = ModuleManifest(
summary="Starts a replay-safe case from an exact available Service revision.", summary="Starts a replay-safe case from an exact available Service revision.",
contract_version="0.1.0", 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( migration_spec=MigrationSpec(
module_id=MODULE_ID, module_id=MODULE_ID,
@@ -347,6 +360,48 @@ manifest = ModuleManifest(
resource_acl_providers=(CaseAclProvider(),), resource_acl_providers=(CaseAclProvider(),),
tenant_summary_providers=(_tenant_summary,), tenant_summary_providers=(_tenant_summary,),
documentation=( 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( DocumentationTopic(
id="cases.institutional-context", id="cases.institutional-context",
title="Case institutional context", title="Case institutional context",
+115
View File
@@ -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",
]
+107
View File
@@ -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()