From 23f5091a2b81efc30e82d6c1be820ba1b1760cbc Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 6 Aug 2026 12:42:20 +0200 Subject: [PATCH] Integrate formal decisions with cases --- docs/CONCEPT.md | 22 +- src/govoplan_cases/backend/decision_path.py | 478 ++++++++++++++++++ src/govoplan_cases/backend/manifest.py | 81 ++- src/govoplan_cases/backend/router.py | 86 +++- src/govoplan_cases/backend/schemas.py | 14 + src/govoplan_cases/backend/search_source.py | 283 +++++++++++ src/govoplan_cases/backend/service.py | 2 + src/govoplan_cases/backend/service_intake.py | 4 + tests/test_decision_path.py | 350 +++++++++++++ .../test_interface_documentation_contract.py | 2 + tests/test_search_source.py | 174 +++++++ webui/src/api/cases.ts | 63 +++ .../src/features/cases/CaseDecisionDialog.tsx | 209 ++++++++ webui/src/features/cases/CaseDetailPage.tsx | 162 +++++- webui/src/i18n/generatedTranslations.ts | 48 +- webui/src/module.ts | 6 +- webui/src/styles/cases.css | 87 +++- 17 files changed, 2048 insertions(+), 23 deletions(-) create mode 100644 src/govoplan_cases/backend/decision_path.py create mode 100644 src/govoplan_cases/backend/search_source.py create mode 100644 tests/test_decision_path.py create mode 100644 tests/test_search_source.py create mode 100644 webui/src/features/cases/CaseDecisionDialog.tsx diff --git a/docs/CONCEPT.md b/docs/CONCEPT.md index cb50831..5134737 100644 --- a/docs/CONCEPT.md +++ b/docs/CONCEPT.md @@ -163,6 +163,15 @@ Cases links formal Decision records and may retain a current outcome/status projection. It does not own decision authority, rule versions, reasoning, correction, revocation, or remedy semantics. +The Case detail decision path is a guarded composition boundary. It resolves an +exact Case revision, rechecks Case update access, verifies the current acting +assignment through Access, and requires exactly one effective Mandate for +`cases.formal_decision` and the selected Decision type. Only then does it call +the optional Decisions registry and append the exact Decision reference to a +new Case revision in the same transaction. Deterministic identifiers make an +unchanged retry safe, while changed replay payloads and stale Case revisions +fail closed. Cases still does not store the protected result or reasoning. + Evidence links should store only stable module/resource references and display metadata snapshots. The owning module remains responsible for the real object. @@ -173,6 +182,12 @@ 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. +When Search is enabled, `cases.cases` indexes current Case titles, numbers, +types, states, and the existing bounded search projection. It includes no +provider-owned evidence content or protected Decision reasoning. Restricted +Case candidates carry bounded ACL tokens and every result is checked again +against the current Case object-access decision before disclosure. + ## WebUI Initial route contributions: @@ -180,8 +195,11 @@ Initial route contributions: - `/cases` - `/cases/:caseId` -The initial detail view renders the module-owned record, stable references, -history, and timeline. Future linked forms, files, tasks, workflow state, +The detail view renders the module-owned record, stable references, history, +timeline, and provider-resolved formal Decisions when currently authorized. It +can record a direct officer Decision when Access, Mandates, and Decisions are +enabled and can launch exact Case or Decision filing when Records is enabled. +Future linked forms, files, tasks, workflow state, appointments, documents, communication, payment evidence, and richer audit panels must arrive through declarative extension points, without direct UI imports from sibling modules. diff --git a/src/govoplan_cases/backend/decision_path.py b/src/govoplan_cases/backend/decision_path.py new file mode 100644 index 0000000..d4ae73a --- /dev/null +++ b/src/govoplan_cases/backend/decision_path.py @@ -0,0 +1,478 @@ +from __future__ import annotations + +from dataclasses import dataclass, replace +from datetime import datetime +import uuid + +from sqlalchemy.orm import Session + +from govoplan_core.core.access import CAPABILITY_ACCESS_SEMANTIC_DIRECTORY +from govoplan_core.core.institutional import ( + CAPABILITY_DECISION_REGISTRY, + CAPABILITY_MANDATE_RESOLVER, + ActorRepresentationReference, + DecisionRegistry, + FormalDecision, + GovernedContextEnvelope, + InstitutionalReference, + MandateDefinition, + MandateResolution, + MandateResolutionRequest, + MandateResolver, + TemporalRevision, + resolve_mandate_candidates, +) +from govoplan_cases.backend.domain import CaseRecord +from govoplan_cases.backend.service import ( + can_access_case, + get_case, + update_case, +) + + +DECISION_READ_SCOPE = "decisions:decision:read" +DECISION_SENSITIVE_READ_SCOPE = "decisions:decision:read_sensitive" +DECISION_WRITE_SCOPE = "decisions:decision:write" +_DECISION_NAMESPACE = uuid.uuid5( + uuid.NAMESPACE_URL, + "https://govoplan.add-ideas.de/contracts/cases/formal-decision/v1", +) + + +class CaseDecisionError(ValueError): + pass + + +class CaseDecisionUnavailable(CaseDecisionError): + pass + + +@dataclass(frozen=True, slots=True) +class CaseDecisionCommand: + expected_case_revision: int + effective_at: datetime + decision_type: str + operative_result: str + reasoning: str + change_reason: str + idempotency_key: str + conditions: tuple[str, ...] = () + + +@dataclass(frozen=True, slots=True) +class CaseDecisionResult: + case: CaseRecord + decision: FormalDecision + replayed: bool = False + + def to_dict(self) -> dict[str, object]: + return { + "case": self.case.to_dict(), + "decision": self.decision.to_dict(include_protected=True), + "replayed": self.replayed, + } + + +class CaseDecisionPath: + """Record a formal outcome and its exact Case reference atomically.""" + + def __init__(self, registry: object | None = None) -> None: + self._registry = registry + + def record( + self, + session: Session, + principal: object, + *, + case_id: str, + command: CaseDecisionCommand, + ) -> CaseDecisionResult: + _validate_command(command) + source_case = get_case( + session, + principal, + case_id=case_id, + revision=command.expected_case_revision, + ) + current = get_case(session, principal, case_id=case_id) + if source_case is None or current is None: + raise LookupError("Case not found.") + if not can_access_case( + session, + principal, + case_id=case_id, + permission="update", + ): + raise PermissionError("Case update access is denied.") + + decision_id = _decision_id(source_case, command.idempotency_key) + existing_ref = next( + ( + item + for item in current.decision_refs + if item.owner_module == "decisions" and item.object_id == decision_id + ), + None, + ) + if existing_ref is None and current.revision != command.expected_case_revision: + raise CaseDecisionError( + "Case revision conflict: reload the Case before recording a Decision." + ) + + decision_registry = _decision_registry(self._registry) + mandate = _resolve_mandate( + self._registry, + session, + principal, + case=source_case, + command=command, + ) + actor = _resolve_actor( + self._registry, + principal, + case=source_case, + mandate=mandate, + effective_at=command.effective_at, + ) + decision = _decision( + source_case, + command=command, + decision_id=decision_id, + mandate=mandate, + actor=actor, + ) + persisted = decision_registry.record_decision( + session, + principal, + decision=decision, + ) + + if existing_ref is not None: + return CaseDecisionResult( + case=current, + decision=persisted, + replayed=True, + ) + + linked = update_case( + session, + principal, + case_id=case_id, + expected_revision=current.revision, + changes={"decision_refs": (*current.decision_refs, persisted.reference)}, + recorded_at=command.effective_at, + change_reason=command.change_reason, + idempotency_key=f"formal-decision:{command.idempotency_key}", + ) + return CaseDecisionResult(case=linked, decision=persisted) + + def linked( + self, + session: Session, + principal: object, + *, + case_id: str, + ) -> tuple[FormalDecision, ...]: + case = get_case(session, principal, case_id=case_id) + if case is None: + raise LookupError("Case not found.") + registry = _decision_registry(self._registry) + resolved: list[FormalDecision] = [] + for reference in case.decision_refs: + if reference.owner_module != "decisions": + continue + item = registry.get_decision( + session, + principal, + reference=reference, + ) + if item is not None: + resolved.append(item) + return tuple(resolved) + + +def _decision( + case: CaseRecord, + *, + command: CaseDecisionCommand, + decision_id: str, + mandate: MandateDefinition, + actor: ActorRepresentationReference, +) -> FormalDecision: + evidence = tuple( + dict.fromkeys((*case.evidence_refs, *case.context.evidence, *mandate.evidence)) + ) + legal_bases = tuple( + dict.fromkeys((*case.context.legal_bases, *mandate.legal_bases)) + ) + if not evidence: + raise CaseDecisionError( + "A formal Decision requires evidence linked to the exact Case revision." + ) + if not legal_bases: + raise CaseDecisionError( + "A formal Decision requires an effective legal-basis version." + ) + decision_ref = InstitutionalReference( + kind="decision", + owner_module="decisions", + object_id=decision_id, + tenant_id=case.reference.tenant_id, + version="1", + valid_at=command.effective_at, + ) + temporal = TemporalRevision( + revision="1", + valid_from=command.effective_at, + recorded_at=command.effective_at, + change_reason=command.change_reason, + ) + authority_context: GovernedContextEnvelope = replace( + case.context, + temporal=temporal, + actor=actor, + mandate_ref=mandate.reference, + case_ref=case.reference, + party_refs=case.party_refs or case.context.party_refs, + decision_ref=decision_ref, + record_refs=case.record_refs, + legal_bases=legal_bases, + evidence=evidence, + ) + return FormalDecision( + reference=decision_ref, + temporal=temporal, + decision_type=command.decision_type.strip(), + subject_refs=case.party_refs or (case.reference,), + state="decided", + authority_context=authority_context, + fact_evidence=evidence, + legal_bases=legal_bases, + operative_result=command.operative_result.strip(), + reasoning=command.reasoning.strip(), + conditions=tuple(item.strip() for item in command.conditions if item.strip()), + ) + + +def _resolve_mandate( + registry: object | None, + session: Session, + principal: object, + *, + case: CaseRecord, + command: CaseDecisionCommand, +) -> MandateDefinition: + resolver = _capability(registry, CAPABILITY_MANDATE_RESOLVER) + if not isinstance(resolver, MandateResolver): + raise CaseDecisionUnavailable( + "Formal Decisions require an enabled Mandates resolver." + ) + request = MandateResolutionRequest( + tenant_id=case.reference.tenant_id, + effective_at=command.effective_at, + task_type="cases.formal_decision", + authority_type=command.decision_type.strip(), + organization_unit_ref=case.context.organization_unit_ref, + function_ref=case.context.function_ref, + jurisdiction_refs=case.context.jurisdiction_refs, + ) + resolution: MandateResolution = resolver.resolve_mandate( + session, + principal, + request=request, + ) + if not resolution.competent: + raise CaseDecisionError( + resolution.explanation or "The acting function is not competent to decide." + ) + if resolution.conflict_refs: + raise CaseDecisionError( + "Mandate resolution has unresolved conflicts: " + + ", ".join(resolution.conflict_refs) + ) + verified = resolve_mandate_candidates(request, resolution.mandates) + if not verified.competent or len(verified.mandates) != 1: + raise CaseDecisionError( + verified.explanation + or "A formal Decision requires exactly one effective active Mandate." + ) + return verified.mandates[0] + + +def _resolve_actor( + registry: object | None, + principal: object, + *, + case: CaseRecord, + mandate: MandateDefinition, + effective_at: datetime, +) -> ActorRepresentationReference: + directory = _capability(registry, CAPABILITY_ACCESS_SEMANTIC_DIRECTORY) + lookup = getattr(directory, "get_function_assignment", None) + if not callable(lookup): + raise CaseDecisionUnavailable( + "Formal Decisions require the Access semantic directory to verify the acting assignment." + ) + acting_id = _text(getattr(principal, "acting_assignment_id", None)) + granted_ids = tuple( + sorted( + { + str(item).strip() + for item in (getattr(principal, "function_assignment_ids", ()) or ()) + if str(item).strip() + } + ) + ) + if acting_id and acting_id not in granted_ids: + raise PermissionError("The selected acting assignment is not active for this account.") + candidate_ids = (acting_id,) if acting_id else granted_ids + matching = [] + for assignment_id in candidate_ids: + assignment = lookup(assignment_id) + if assignment is None or not _assignment_matches( + assignment, + principal, + case=case, + effective_at=effective_at, + ): + continue + matching.append(assignment) + if not matching: + raise PermissionError( + "No active acting assignment matches the Case organization and function." + ) + if len(matching) > 1: + raise CaseDecisionError( + "Select one acting assignment in the title bar before recording the Decision." + ) + assignment = matching[0] + return ActorRepresentationReference( + tenant_id=case.reference.tenant_id, + account_id=_required_principal_text(principal, "account_id"), + identity_id=_text(getattr(principal, "identity_id", None)) + or _text(getattr(assignment, "identity_id", None)), + represented_account_id=_text( + getattr(principal, "acting_for_account_id", None) + ), + represented_function_ref=case.context.function_ref, + function_assignment_ref=InstitutionalReference( + kind="function_assignment", + owner_module="access", + object_id=str(getattr(assignment, "id")), + tenant_id=case.reference.tenant_id, + valid_at=effective_at, + ), + delegation_ref=_text( + getattr(assignment, "delegated_from_assignment_id", None) + ), + mandate_ref=mandate.reference, + ) + + +def _assignment_matches( + assignment: object, + principal: object, + *, + case: CaseRecord, + effective_at: datetime, +) -> bool: + organization = case.context.organization_unit_ref + function = case.context.function_ref + if organization is None or function is None: + raise CaseDecisionError( + "The Case lacks the responsible organization and function required for a formal Decision." + ) + if str(getattr(assignment, "tenant_id", "")) != case.reference.tenant_id: + return False + if str(getattr(assignment, "account_id", "")) != _required_principal_text( + principal, "account_id" + ): + return False + if str(getattr(assignment, "function_id", "")) != function.object_id: + return False + if str(getattr(assignment, "organization_unit_id", "")) != organization.object_id: + return False + if str(getattr(assignment, "status", "active")) != "active": + return False + valid_from = getattr(assignment, "valid_from", None) + valid_until = getattr(assignment, "valid_until", None) + return not ( + (valid_from is not None and effective_at < valid_from) + or (valid_until is not None and effective_at >= valid_until) + ) + + +def _decision_registry(registry: object | None) -> DecisionRegistry: + provider = _capability(registry, CAPABILITY_DECISION_REGISTRY) + if not isinstance(provider, DecisionRegistry): + raise CaseDecisionUnavailable( + "Formal Decisions require an enabled Decision registry." + ) + return provider + + +def _decision_id(case: CaseRecord, idempotency_key: str) -> str: + return str( + uuid.uuid5( + _DECISION_NAMESPACE, + ":".join( + ( + case.reference.tenant_id, + case.reference.object_id, + str(case.revision), + idempotency_key.strip(), + ) + ), + ) + ) + + +def _validate_command(command: CaseDecisionCommand) -> None: + for value, label in ( + (command.decision_type, "Decision type"), + (command.operative_result, "Operative result"), + (command.reasoning, "Decision reasoning"), + (command.change_reason, "Decision change reason"), + (command.idempotency_key, "Decision idempotency key"), + ): + if not value.strip(): + raise CaseDecisionError(f"{label} is required.") + if command.expected_case_revision < 1: + raise CaseDecisionError("Expected Case revision must be positive.") + if command.effective_at.tzinfo is None or command.effective_at.utcoffset() is None: + raise CaseDecisionError("Decision effective_at must include a timezone.") + + +def _capability(registry: object | None, name: str) -> object | None: + if ( + registry is None + or not hasattr(registry, "has_capability") + or not hasattr(registry, "capability") + or not registry.has_capability(name) + ): + return None + return registry.capability(name) + + +def _text(value: object | None) -> str | None: + normalized = str(value or "").strip() + return normalized or None + + +def _required_principal_text(principal: object, name: str) -> str: + value = _text(getattr(principal, name, None)) + if value is None: + raise PermissionError(f"The current principal has no {name.replace('_', ' ')}.") + return value + + +__all__ = [ + "CaseDecisionCommand", + "CaseDecisionError", + "CaseDecisionPath", + "CaseDecisionResult", + "CaseDecisionUnavailable", + "DECISION_READ_SCOPE", + "DECISION_SENSITIVE_READ_SCOPE", + "DECISION_WRITE_SCOPE", +] diff --git a/src/govoplan_cases/backend/manifest.py b/src/govoplan_cases/backend/manifest.py index 7815468..c048c1b 100644 --- a/src/govoplan_cases/backend/manifest.py +++ b/src/govoplan_cases/backend/manifest.py @@ -6,7 +6,12 @@ from govoplan_core.core.module_guards import ( drop_table_retirement_provider, persistent_table_uninstall_guard, ) -from govoplan_core.core.institutional import CAPABILITY_PARTY_RESOLVER +from govoplan_core.core.access import CAPABILITY_ACCESS_SEMANTIC_DIRECTORY +from govoplan_core.core.institutional import ( + CAPABILITY_DECISION_REGISTRY, + CAPABILITY_MANDATE_RESOLVER, + CAPABILITY_PARTY_RESOLVER, +) from govoplan_core.core.modules import ( CapabilityDocumentation, DocumentationCondition, @@ -24,6 +29,7 @@ from govoplan_core.core.modules import ( RoleTemplate, ) from govoplan_core.core.views import ViewSurface +from govoplan_core.core.search import SearchSourceProviderRegistration from govoplan_core.core.provider_governance import ( ModuleArchitectureDeclaration, ModuleArchitectureDocumentation, @@ -51,6 +57,7 @@ from govoplan_cases.backend.record_source import ( CAPABILITY_RECORD_SOURCE_CASES, create_cases_record_source, ) +from govoplan_cases.backend.search_source import create_cases_search_source from govoplan_core.db.base import Base @@ -138,7 +145,12 @@ manifest = ModuleManifest( "workflow_engine", "records", ), - optional_capabilities=(CAPABILITY_PARTY_RESOLVER,), + optional_capabilities=( + CAPABILITY_PARTY_RESOLVER, + CAPABILITY_ACCESS_SEMANTIC_DIRECTORY, + CAPABILITY_MANDATE_RESOLVER, + CAPABILITY_DECISION_REGISTRY, + ), permissions=( _permission(READ_SCOPE, "View cases", "Read tenant cases and their governed history."), _permission(CREATE_SCOPE, "Create cases", "Open a case from a configured case type or service intake."), @@ -282,6 +294,14 @@ manifest = ModuleManifest( parent_id="cases.detail", order=60, ), + ViewSurface( + id="cases.detail.decision", + module_id=MODULE_ID, + kind="action", + label="Prepare formal decision", + parent_id="cases.detail", + order=70, + ), ), ), provides_interfaces=( @@ -295,6 +315,8 @@ manifest = ModuleManifest( ModuleInterfaceRequirement(name="services.definition", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True), ModuleInterfaceRequirement(name="parties.procedure", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True), ModuleInterfaceRequirement(name="parties.representation", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True), + ModuleInterfaceRequirement(name="mandates.resolution", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True), + ModuleInterfaceRequirement(name="decisions.formal_outcome", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True), ), capability_factories={ CAPABILITY_CASES_SERVICE_INTAKE: _service_intake, @@ -358,8 +380,57 @@ manifest = ModuleManifest( ), ), resource_acl_providers=(CaseAclProvider(),), + search_sources=( + SearchSourceProviderRegistration( + id="cases.cases", + factory=create_cases_search_source, + ), + ), tenant_summary_providers=(_tenant_summary,), documentation=( + DocumentationTopic( + id="cases.workflow.record-formal-decision", + title="Record a formal Decision from a Case", + summary="Revalidate acting authority and preserve an exact Decision revision in the Case history.", + body=( + "The Case detail action accepts only the operative result, reasoning, conditions, effective time, and change reason. " + "Cases resolves the exact Case revision, verifies object update access, resolves the current acting assignment through " + "Access, and requires exactly one effective Mandate for cases.formal_decision and the selected Decision type. It then " + "records the outcome through the optional Decisions registry and links the exact Decision revision to a new Case revision " + "in the same database transaction. A replay uses a deterministic Decision identifier; changed replay payloads fail closed." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("case_manager", "decision_officer", "operator", "auditor"), + related_modules=("access", "mandates", "decisions", "records"), + order=14, + conditions=( + DocumentationCondition( + required_modules=("cases", "access", "mandates", "decisions"), + required_scopes=("cases:case:update", "decisions:decision:write"), + ), + ), + links=( + DocumentationLink(label="Cases", href="/cases", kind="runtime"), + DocumentationLink(label="Cases concept", href="govoplan-cases/docs/CONCEPT.md", kind="repository"), + DocumentationLink(label="Decisions domain", href="govoplan-decisions/docs/DECISIONS_DOMAIN.md", kind="repository"), + ), + metadata={ + "kind": "workflow", + "help_contexts": ["cases.action.decide", "cases.decision.editor"], + "prerequisites": [ + "The Case has an exact responsible organization, function, evidence, and legal-basis version.", + "An active acting assignment and exactly one effective Mandate authorize the Decision type.", + ], + "steps": [ + "Open the Case and select the appropriate acting assignment in the title bar.", + "Record the operative result, reasoning, optional conditions, effective time, and change reason.", + "Confirm the exact Decision and Case revisions in the Case timeline.", + "When Records is enabled, file the exact Decision or Case revision into the destination eAkte.", + ], + "outcome": "The formal outcome is Decisions-owned and exact-version linked from the Case.", + }, + ), DocumentationTopic( id="cases.workflow.file-exact-revision", title="File an exact case revision into an eAkte", @@ -412,7 +483,10 @@ manifest = ModuleManifest( "intake retains the exact Service, Mandate, jurisdiction, legal basis, form, " "workflow, and result bindings. Procedure parties come from an optional provider " "or a limited Cases-only compatibility projection. Assignment, evidence, Decision, " - "and record links remain stable references owned by their source modules." + "and record links remain stable references owned by their source modules. When " + "Search is enabled, Cases contributes a rebuildable metadata-only projection. " + "Evidence and protected Decision content are excluded, and every candidate is " + "rechecked against current Case access before disclosure." ), layer="available", documentation_types=("admin", "user"), @@ -428,6 +502,7 @@ manifest = ModuleManifest( "help_contexts": [ "cases.list", "cases.detail", + "cases.search.result", "cases.state.read-only", "cases.state.restricted", ], diff --git a/src/govoplan_cases/backend/router.py b/src/govoplan_cases/backend/router.py index f0bb00a..63f2c7b 100644 --- a/src/govoplan_cases/backend/router.py +++ b/src/govoplan_cases/backend/router.py @@ -32,6 +32,7 @@ from govoplan_cases.backend.manifest import ( UPDATE_SCOPE, ) from govoplan_cases.backend.schemas import ( + CaseDecisionRequest, CaseHistoryResponse, CaseListResponse, CaseStatusWriteRequest, @@ -40,6 +41,15 @@ from govoplan_cases.backend.schemas import ( CaseUpdateRequest, CaseWriteRequest, ) +from govoplan_cases.backend.decision_path import ( + CaseDecisionCommand, + CaseDecisionError, + CaseDecisionPath, + CaseDecisionUnavailable, + DECISION_READ_SCOPE, + DECISION_SENSITIVE_READ_SCOPE, + DECISION_WRITE_SCOPE, +) from govoplan_cases.backend.service import ( CaseStoreError, can_access_case, @@ -66,7 +76,9 @@ def _require(principal: ApiPrincipal, scope: str) -> None: def _error(exc: Exception) -> HTTPException: message = str(exc) lowered = message.casefold() - if isinstance(exc, LookupError): + if isinstance(exc, CaseDecisionUnavailable): + code = status.HTTP_424_FAILED_DEPENDENCY + elif isinstance(exc, LookupError): code = 404 elif isinstance(exc, PermissionError): code = 403 @@ -206,6 +218,78 @@ def api_create_case( return item.to_dict() +@router.get("/{case_id}/decisions", response_model=dict[str, list[dict[str, Any]]]) +def api_case_decisions( + case_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, list[dict[str, Any]]]: + _require(principal, READ_SCOPE) + _require(principal, DECISION_READ_SCOPE) + try: + items = CaseDecisionPath(get_registry()).linked( + session, + principal, + case_id=case_id, + ) + except ( + CaseDecisionError, + InstitutionalContextError, + LookupError, + PermissionError, + ) as exc: + raise _error(exc) from exc + disclose = has_scope(principal, DECISION_SENSITIVE_READ_SCOPE) + return { + "decisions": [ + item.to_dict(include_protected=disclose) + for item in items + ] + } + + +@router.post( + "/{case_id}/decisions", + response_model=dict[str, Any], + status_code=status.HTTP_201_CREATED, +) +def api_record_case_decision( + case_id: str, + payload: CaseDecisionRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, Any]: + _require(principal, UPDATE_SCOPE) + _require(principal, DECISION_WRITE_SCOPE) + try: + result = CaseDecisionPath(get_registry()).record( + session, + principal, + case_id=case_id, + command=CaseDecisionCommand( + expected_case_revision=payload.expected_case_revision, + effective_at=payload.effective_at, + decision_type=payload.decision_type, + operative_result=payload.operative_result, + reasoning=payload.reasoning, + conditions=tuple(payload.conditions), + change_reason=payload.change_reason, + idempotency_key=payload.idempotency_key, + ), + ) + session.commit() + except ( + CaseDecisionError, + InstitutionalContextError, + LookupError, + PermissionError, + ValueError, + ) as exc: + session.rollback() + raise _error(exc) from exc + return result.to_dict() + + @router.get("/{case_id}", response_model=dict[str, Any]) def api_get_case( case_id: str, diff --git a/src/govoplan_cases/backend/schemas.py b/src/govoplan_cases/backend/schemas.py index 444337f..61452ea 100644 --- a/src/govoplan_cases/backend/schemas.py +++ b/src/govoplan_cases/backend/schemas.py @@ -82,6 +82,19 @@ class CaseUpdateRequest(BaseModel): metadata: dict[str, Any] | None = None +class CaseDecisionRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + expected_case_revision: int = Field(ge=1) + effective_at: datetime + decision_type: str = Field(min_length=1, max_length=120) + operative_result: str = Field(min_length=1, max_length=20_000) + reasoning: str = Field(min_length=1, max_length=50_000) + conditions: list[str] = Field(default_factory=list, max_length=100) + change_reason: str = Field(min_length=1, max_length=1_000) + idempotency_key: str = Field(min_length=1, max_length=255) + + class CaseListResponse(BaseModel): cases: list[dict[str, Any]] total: int @@ -99,6 +112,7 @@ class CaseTimelineResponse(BaseModel): __all__ = [ "CaseGrantRequest", + "CaseDecisionRequest", "CaseHistoryResponse", "CaseListResponse", "CaseStatusWriteRequest", diff --git a/src/govoplan_cases/backend/search_source.py b/src/govoplan_cases/backend/search_source.py new file mode 100644 index 0000000..b440344 --- /dev/null +++ b/src/govoplan_cases/backend/search_source.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from urllib.parse import quote + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.events import PlatformEvent +from govoplan_core.core.modules import ModuleContext +from govoplan_core.core.search import ( + SearchAuthorizationRequest, + SearchBackfillPage, + SearchBackfillRequest, + SearchDocument, + SearchIndexChange, + SearchResourceReference, + SearchResourceType, +) +from govoplan_cases.backend.db.models import ( + CaseAccessGrant, + CaseIdentity, + CaseRecordRevision, +) +from govoplan_cases.backend.service import can_access_case + + +PROVIDER_ID = "cases.cases" +RESOURCE_TYPE = "case" +READ_SCOPE = "cases:case:read" +ADMIN_SCOPE = "cases:case:admin" + + +class CasesSearchSource: + def resource_types(self) -> Sequence[SearchResourceType]: + return ( + SearchResourceType( + provider_id=PROVIDER_ID, + module_id="cases", + resource_type=RESOURCE_TYPE, + label="Cases", + requires_authorization_recheck=True, + ), + ) + + def backfill( + self, + session: object, + *, + request: SearchBackfillRequest, + ) -> SearchBackfillPage: + _assert_source(request.provider_id, request.resource_type) + db = _session(session) + statement = ( + select(CaseRecordRevision, CaseIdentity) + .join(CaseIdentity, CaseIdentity.id == CaseRecordRevision.identity_id) + .where( + CaseRecordRevision.tenant_id == request.tenant_id, + CaseRecordRevision.superseded_at.is_(None), + ) + ) + if request.cursor: + statement = statement.where(CaseRecordRevision.case_id > request.cursor) + rows = list( + db.execute( + statement.order_by(CaseRecordRevision.case_id).limit(request.limit + 1) + ).all() + ) + has_more = len(rows) > request.limit + selected = rows[: request.limit] + grants = _grants_by_case( + db, + request.tenant_id, + [row.case_id for row, _identity in selected], + ) + high_watermark = db.scalar( + select(func.max(CaseRecordRevision.recorded_at)).where( + CaseRecordRevision.tenant_id == request.tenant_id, + CaseRecordRevision.superseded_at.is_(None), + ) + ) + return SearchBackfillPage( + documents=tuple( + _document( + row, + identity=identity, + grants=grants.get(row.case_id, ()), + ) + for row, identity in selected + ), + next_cursor=selected[-1][0].case_id if has_more and selected else None, + complete=not has_more, + high_watermark=high_watermark.isoformat() if high_watermark else None, + ) + + def authorize( + self, + session: object, + principal: object, + *, + requests: Sequence[SearchAuthorizationRequest], + ) -> Mapping[str, bool]: + decisions = {item.reference.key: False for item in requests} + if not isinstance(principal, ApiPrincipal) or not principal.has(READ_SCOPE): + return decisions + db = _session(session) + for request in requests: + reference = request.reference + if ( + reference.tenant_id != principal.tenant_id + or reference.module_id != "cases" + or reference.resource_type != RESOURCE_TYPE + ): + continue + decisions[reference.key] = can_access_case( + db, + principal, + case_id=reference.resource_id, + permission="read", + ) + return decisions + + def index_changes_for_event( + self, + session: object, + *, + event: PlatformEvent, + delivery_key: str, + ) -> Sequence[SearchIndexChange]: + if ( + event.module_id != "cases" + or event.tenant is None + or event.resource is None + or event.resource.type != RESOURCE_TYPE + or event.resource.id is None + ): + return () + db = _session(session) + row = db.scalar( + select(CaseRecordRevision).where( + CaseRecordRevision.tenant_id == event.tenant.id, + CaseRecordRevision.case_id == event.resource.id, + CaseRecordRevision.superseded_at.is_(None), + ) + ) + identity = db.scalar( + select(CaseIdentity).where( + CaseIdentity.tenant_id == event.tenant.id, + CaseIdentity.case_id == event.resource.id, + ) + ) + deleted = row is None or identity is None + cursor = event.event_id + document = None + if not deleted: + document = _document( + row, + identity=identity, + grants=_grants_by_case( + db, + event.tenant.id, + [row.case_id], + ).get(row.case_id, ()), + change_cursor=cursor, + ) + reference = SearchResourceReference( + tenant_id=event.tenant.id, + module_id="cases", + resource_type=RESOURCE_TYPE, + resource_id=event.resource.id, + ) + return ( + SearchIndexChange( + change_id=f"{delivery_key}:{PROVIDER_ID}", + provider_id=PROVIDER_ID, + kind="delete" if deleted else "upsert", + reference=reference, + source_revision=(document.source_revision if document else cursor), + cursor=cursor, + document=document, + occurred_at=event.occurred_at, + ), + ) + + +def create_cases_search_source(_context: ModuleContext) -> CasesSearchSource: + return CasesSearchSource() + + +def _document( + row: CaseRecordRevision, + *, + identity: CaseIdentity, + grants: Sequence[CaseAccessGrant], + change_cursor: str | None = None, +) -> SearchDocument: + tokens = [f"scope:{READ_SCOPE}", f"scope:{ADMIN_SCOPE}"] + if identity.created_by: + tokens.extend( + (f"account:{identity.created_by}", f"membership:{identity.created_by}") + ) + prefixes = { + "account": "account", + "identity": "identity", + "group": "group", + "role": "role", + "function": "function", + "function_assignment": "function", + } + for grant in grants: + prefix = prefixes.get(grant.subject_kind) + if prefix: + tokens.append(f"{prefix}:{grant.subject_id}") + return SearchDocument( + tenant_id=row.tenant_id, + module_id="cases", + provider_id=PROVIDER_ID, + resource_type=RESOURCE_TYPE, + resource_id=row.case_id, + title=row.title, + url=f"/cases/{quote(row.case_id, safe='')}", + summary=f"{identity.case_number} - {row.status_key}", + body=row.search_text[:200_000], + keywords=( + identity.case_number[:200], + row.case_type_key[:200], + row.status_key[:200], + ), + visibility="restricted", + acl_tokens=tuple(dict.fromkeys(tokens)), + metadata={ + "case_number": identity.case_number, + "case_type_key": row.case_type_key, + "status_key": row.status_key, + "access_mode": row.access_mode, + }, + source_revision=str(row.revision), + change_cursor=change_cursor, + source_updated_at=row.recorded_at, + requires_authorization_recheck=True, + ) + + +def _grants_by_case( + session: Session, + tenant_id: str, + case_ids: Sequence[str], +) -> dict[str, tuple[CaseAccessGrant, ...]]: + grouped: dict[str, list[CaseAccessGrant]] = { + case_id: [] for case_id in case_ids + } + if not case_ids: + return {} + rows = session.scalars( + select(CaseAccessGrant).where( + CaseAccessGrant.tenant_id == tenant_id, + CaseAccessGrant.case_id.in_(tuple(case_ids)), + CaseAccessGrant.active.is_(True), + ) + ) + for row in rows: + grouped.setdefault(row.case_id, []).append(row) + return {key: tuple(value) for key, value in grouped.items()} + + +def _assert_source(provider_id: str, resource_type: str) -> None: + if provider_id != PROVIDER_ID or resource_type != RESOURCE_TYPE: + raise ValueError("Unsupported Cases search source.") + + +def _session(value: object) -> Session: + if not isinstance(value, Session): + raise TypeError("Cases search requires a SQLAlchemy session.") + return value + + +__all__ = [ + "CasesSearchSource", + "PROVIDER_ID", + "RESOURCE_TYPE", + "create_cases_search_source", +] diff --git a/src/govoplan_cases/backend/service.py b/src/govoplan_cases/backend/service.py index b11a81f..0e2e778 100644 --- a/src/govoplan_cases/backend/service.py +++ b/src/govoplan_cases/backend/service.py @@ -331,6 +331,8 @@ def create_case_from_intake( "result_refs": list(plan.result_refs), "required_evidence_types": list(plan.required_evidence_types), "deadline_refs": list(plan.deadline_refs), + "remedy_refs": list(plan.remedy_refs), + "service_level_refs": list(plan.service_level_refs), }, ) return create_case( diff --git a/src/govoplan_cases/backend/service_intake.py b/src/govoplan_cases/backend/service_intake.py index c24e95a..28eb4fc 100644 --- a/src/govoplan_cases/backend/service_intake.py +++ b/src/govoplan_cases/backend/service_intake.py @@ -26,6 +26,8 @@ class CaseIntakePlan: result_refs: tuple[str, ...] = () required_evidence_types: tuple[str, ...] = () deadline_refs: tuple[str, ...] = () + remedy_refs: tuple[str, ...] = () + service_level_refs: tuple[str, ...] = () class CaseServiceIntake: @@ -91,6 +93,8 @@ class CaseServiceIntake: result_refs=_binding_refs(definition, "result"), required_evidence_types=definition.required_evidence_types, deadline_refs=definition.deadline_refs, + remedy_refs=definition.remedy_refs, + service_level_refs=definition.service_level_refs, ) diff --git a/tests/test_decision_path.py b/tests/test_decision_path.py new file mode 100644 index 0000000..7d61434 --- /dev/null +++ b/tests/test_decision_path.py @@ -0,0 +1,350 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from govoplan_core.core.access import ( + CAPABILITY_ACCESS_SEMANTIC_DIRECTORY, + FunctionAssignmentRef, +) +from govoplan_core.core.institutional import ( + CAPABILITY_DECISION_REGISTRY, + CAPABILITY_MANDATE_RESOLVER, + EvidenceReference, + FormalDecision, + GovernedContextEnvelope, + InstitutionalReference, + LegalBasisReference, + MandateDefinition, + MandateResolution, + TemporalRevision, +) +from govoplan_cases.backend.db.models import ( + CaseAccessGrant, + CaseIdentity, + CaseRecordRevision, + CaseStatusDefinition, + CaseTimelineEntry, + CaseTypeDefinition, +) +from govoplan_cases.backend.decision_path import ( + CaseDecisionCommand, + CaseDecisionError, + CaseDecisionPath, +) +from govoplan_cases.backend.domain import CaseRecord +from govoplan_cases.backend.service import ( + create_case, + get_case, + upsert_case_status, + upsert_case_type, +) + + +NOW = datetime(2026, 8, 6, 9, 0, tzinfo=UTC) + + +@dataclass +class Principal: + tenant_id: str = "tenant-1" + account_id: str = "account-1" + identity_id: str = "identity-1" + acting_assignment_id: str | None = "assignment-1" + function_assignment_ids: tuple[str, ...] = ("assignment-1",) + scopes: frozenset[str] = frozenset( + {"cases:case:read", "cases:case:update", "decisions:decision:write"} + ) + + +class Registry: + def __init__(self, providers: dict[str, object]) -> None: + self.providers = providers + + def has_capability(self, name: str) -> bool: + return name in self.providers + + def capability(self, name: str) -> object: + return self.providers[name] + + +class AccessDirectory: + def get_function_assignment(self, assignment_id: str): + if assignment_id != "assignment-1": + return None + return FunctionAssignmentRef( + id=assignment_id, + tenant_id="tenant-1", + account_id="account-1", + identity_id="identity-1", + function_id="permit-officer", + organization_unit_id="permits", + valid_from=NOW - timedelta(days=30), + valid_until=NOW + timedelta(days=30), + ) + + +class Mandates: + def __init__(self, mandate: MandateDefinition) -> None: + self.mandate = mandate + + def resolve_mandate(self, session, principal, *, request): + del session, principal + return MandateResolution(competent=True, mandates=(self.mandate,)) + + +class Decisions: + def __init__(self) -> None: + self.items: dict[tuple[str, str], FormalDecision] = {} + + def get_decision(self, session, principal, *, reference): + del session, principal + return self.items.get((reference.object_id, reference.version or "")) + + def record_decision(self, session, principal, *, decision, expected_revision=None): + del session, principal, expected_revision + key = (decision.reference.object_id, decision.reference.version or "") + existing = self.items.get(key) + if existing is not None and existing != decision: + raise ValueError("A different Decision payload already uses this revision.") + self.items[key] = decision + return decision + + +def ref(kind: str, object_id: str, owner: str, *, version: str = "1") -> InstitutionalReference: + return InstitutionalReference( + kind=kind, # type: ignore[arg-type] + owner_module=owner, + object_id=object_id, + tenant_id="tenant-1", + version=version, + valid_at=NOW, + ) + + +def legal_basis() -> LegalBasisReference: + return LegalBasisReference( + kind="law", + authority="Example legislature", + reference="permit-law:3", + version="2026-01", + ) + + +def evidence() -> EvidenceReference: + return EvidenceReference( + kind="document", + owner_module="files", + evidence_id="application-1", + tenant_id="tenant-1", + version="3", + captured_at=NOW, + ) + + +def case_record() -> CaseRecord: + case_ref = ref("case", "case-1", "cases") + context = GovernedContextEnvelope( + tenant_id="tenant-1", + temporal=TemporalRevision( + revision="1", + valid_from=NOW, + recorded_at=NOW, + change_reason="Case intake.", + ), + organization_unit_ref=ref("organization_unit", "permits", "organizations"), + function_ref=ref("function", "permit-officer", "organizations"), + mandate_ref=ref("mandate", "permit-mandate", "mandates", version="4"), + jurisdiction_refs=(ref("jurisdiction", "city-1", "organizations"),), + service_ref=ref("service", "permit", "services", version="4"), + case_ref=case_ref, + legal_bases=(legal_basis(),), + ) + return CaseRecord( + reference=case_ref, + case_number="PERMIT-2026-0001", + case_type_key="permit-application", + status_key="review", + title="Permit application", + context=context, + service_ref=context.service_ref, + party_refs=(ref("party", "applicant", "parties"),), + assignment_refs=(ref("function_assignment", "assignment-1", "access"),), + evidence_refs=(evidence(),), + opened_at=NOW, + recorded_at=NOW, + change_reason="Application received.", + ) + + +def mandate() -> MandateDefinition: + return MandateDefinition( + reference=ref("mandate", "permit-mandate", "mandates", version="4"), + temporal=TemporalRevision( + revision="4", + valid_from=NOW - timedelta(days=30), + valid_to=NOW + timedelta(days=30), + recorded_at=NOW - timedelta(days=31), + change_reason="Permit authority delegated.", + ), + task_types=("cases.formal_decision",), + authority_types=("permit",), + organization_unit_refs=(ref("organization_unit", "permits", "organizations"),), + function_refs=(ref("function", "permit-officer", "organizations"),), + jurisdiction_refs=(ref("jurisdiction", "city-1", "organizations"),), + legal_bases=(legal_basis(),), + evidence=(evidence(),), + ) + + +def command( + *, + key: str = "decision-1", + result: str = "Permit granted.", + expected_case_revision: int = 1, +) -> CaseDecisionCommand: + return CaseDecisionCommand( + expected_case_revision=expected_case_revision, + effective_at=NOW + timedelta(minutes=5), + decision_type="permit", + operative_result=result, + reasoning="The submitted evidence satisfies the effective rule.", + conditions=("Display the permit visibly.",), + change_reason="Formal permit Decision recorded.", + idempotency_key=key, + ) + + +class CaseDecisionPathTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite+pysqlite:///:memory:") + for table in ( + CaseStatusDefinition.__table__, + CaseTypeDefinition.__table__, + CaseIdentity.__table__, + CaseRecordRevision.__table__, + CaseAccessGrant.__table__, + CaseTimelineEntry.__table__, + ): + table.create(self.engine) + self.session = Session(self.engine) + self.principal = Principal() + upsert_case_status( + self.session, + self.principal, + status_key="review", + label="Review", + ) + upsert_case_type( + self.session, + self.principal, + type_key="permit-application", + label="Permit application", + initial_status_key="review", + allowed_status_keys=("review",), + ) + create_case( + self.session, + self.principal, + record=case_record(), + idempotency_key="case-1", + ) + self.session.commit() + self.decisions = Decisions() + self.path = CaseDecisionPath( + Registry( + { + CAPABILITY_ACCESS_SEMANTIC_DIRECTORY: AccessDirectory(), + CAPABILITY_MANDATE_RESOLVER: Mandates(mandate()), + CAPABILITY_DECISION_REGISTRY: self.decisions, + } + ) + ) + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def test_records_and_links_exact_decision_in_one_case_revision(self) -> None: + result = self.path.record( + self.session, + self.principal, + case_id="case-1", + command=command(), + ) + self.session.commit() + + self.assertEqual(2, result.case.revision) + self.assertEqual("Permit granted.", result.decision.operative_result) + self.assertEqual("assignment-1", result.decision.authority_context.actor.function_assignment_ref.object_id) + self.assertEqual("permit-mandate", result.decision.authority_context.mandate_ref.object_id) + self.assertEqual(result.decision.reference, result.case.decision_refs[0]) + self.assertEqual( + result.decision, + self.path.linked(self.session, self.principal, case_id="case-1")[0], + ) + + def test_unchanged_retry_replays_but_changed_payload_fails_closed(self) -> None: + first = self.path.record( + self.session, + self.principal, + case_id="case-1", + command=command(), + ) + self.session.commit() + replay = self.path.record( + self.session, + self.principal, + case_id="case-1", + command=command(), + ) + + self.assertTrue(replay.replayed) + self.assertEqual(first.decision.reference, replay.decision.reference) + with self.assertRaisesRegex(ValueError, "different Decision payload"): + self.path.record( + self.session, + self.principal, + case_id="case-1", + command=command(result="Permit denied."), + ) + + def test_stale_case_revision_and_missing_acting_assignment_fail_closed(self) -> None: + self.path.record( + self.session, + self.principal, + case_id="case-1", + command=command(), + ) + self.session.commit() + + with self.assertRaisesRegex(CaseDecisionError, "revision conflict"): + self.path.record( + self.session, + self.principal, + case_id="case-1", + command=command(key="another-decision"), + ) + without_assignment = Principal( + acting_assignment_id=None, + function_assignment_ids=(), + ) + current = get_case(self.session, self.principal, case_id="case-1") + assert current is not None + with self.assertRaisesRegex(PermissionError, "No active acting assignment"): + self.path.record( + self.session, + without_assignment, + case_id="case-1", + command=command( + key="third-decision", + expected_case_revision=current.revision, + ), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py index f536f3c..c5bfd33 100644 --- a/tests/test_interface_documentation_contract.py +++ b/tests/test_interface_documentation_contract.py @@ -26,6 +26,7 @@ class CasesInterfaceDocumentationContractTests(unittest.TestCase): "cases.detail.timeline", "cases.detail.history", "cases.detail.access", + "cases.detail.decision", }, set(surfaces), ) @@ -37,6 +38,7 @@ class CasesInterfaceDocumentationContractTests(unittest.TestCase): "cases.detail.timeline", "cases.detail.history", "cases.detail.access", + "cases.detail.decision", ): self.assertEqual("cases.detail", surfaces[surface_id].parent_id) diff --git a/tests/test_search_source.py b/tests/test_search_source.py new file mode 100644 index 0000000..78a0b24 --- /dev/null +++ b/tests/test_search_source.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.access import PrincipalRef +from govoplan_core.core.events import EventObjectRef, EventTenantRef, PlatformEvent +from govoplan_core.core.institutional import ( + GovernedContextEnvelope, + InstitutionalReference, + TemporalRevision, +) +from govoplan_core.core.search import ( + SearchAuthorizationRequest, + SearchBackfillRequest, + SearchResourceReference, +) +from govoplan_cases.backend.db.models import ( + CaseAccessGrant, + CaseIdentity, + CaseRecordRevision, + CaseStatusDefinition, + CaseTimelineEntry, + CaseTypeDefinition, +) +from govoplan_cases.backend.domain import CaseRecord +from govoplan_cases.backend.search_source import CasesSearchSource, PROVIDER_ID +from govoplan_cases.backend.service import ( + create_case, + upsert_case_status, + upsert_case_type, +) + + +NOW = datetime(2026, 8, 6, 10, 0, tzinfo=UTC) + + +class CasesSearchSourceTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite+pysqlite:///:memory:") + for table in ( + CaseStatusDefinition.__table__, + CaseTypeDefinition.__table__, + CaseIdentity.__table__, + CaseRecordRevision.__table__, + CaseAccessGrant.__table__, + CaseTimelineEntry.__table__, + ): + table.create(self.engine) + self.session = Session(self.engine) + principal = _principal({"cases:case:read", "cases:case:create"}) + upsert_case_status( + self.session, + principal, + status_key="review", + label="Review", + ) + upsert_case_type( + self.session, + principal, + type_key="permit", + label="Permit", + initial_status_key="review", + ) + case_ref = _ref("case", "case-1", "cases") + create_case( + self.session, + principal, + record=CaseRecord( + reference=case_ref, + case_number="PERMIT-1", + case_type_key="permit", + status_key="review", + title="Permit application", + context=GovernedContextEnvelope( + tenant_id="tenant-1", + temporal=TemporalRevision( + revision="1", + valid_from=NOW, + recorded_at=NOW, + change_reason="Created.", + ), + case_ref=case_ref, + ), + opened_at=NOW, + recorded_at=NOW, + change_reason="Created.", + ), + idempotency_key="case-create", + ) + self.session.commit() + self.source = CasesSearchSource() + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def test_backfill_authorization_and_event_change_are_bounded(self) -> None: + page = self.source.backfill( + self.session, + request=SearchBackfillRequest( + tenant_id="tenant-1", + provider_id=PROVIDER_ID, + resource_type="case", + rebuild_id="rebuild-1", + ), + ) + self.assertEqual(("case-1",), tuple(item.resource_id for item in page.documents)) + self.assertEqual("PERMIT-1", page.documents[0].metadata["case_number"]) + reference = SearchResourceReference( + tenant_id="tenant-1", + module_id="cases", + resource_type="case", + resource_id="case-1", + ) + request = SearchAuthorizationRequest(reference=reference, source_revision="1") + self.assertTrue( + self.source.authorize( + self.session, + _principal({"cases:case:read"}), + requests=(request,), + )[reference.key] + ) + self.assertFalse( + self.source.authorize( + self.session, + _principal(set()), + requests=(request,), + )[reference.key] + ) + changes = self.source.index_changes_for_event( + self.session, + event=PlatformEvent( + type="cases.case.updated", + module_id="cases", + tenant=EventTenantRef(id="tenant-1"), + resource=EventObjectRef(type="case", id="case-1"), + ), + delivery_key="delivery-1", + ) + self.assertEqual("upsert", changes[0].kind) + + +def _ref(kind: str, object_id: str, owner: str) -> InstitutionalReference: + return InstitutionalReference( + kind=kind, # type: ignore[arg-type] + owner_module=owner, + object_id=object_id, + tenant_id="tenant-1", + version="1", + valid_at=NOW, + ) + + +def _principal(scopes: set[str]) -> ApiPrincipal: + return ApiPrincipal( + principal=PrincipalRef( + account_id="account-1", + membership_id="membership-1", + tenant_id="tenant-1", + scopes=frozenset(scopes), + ), + account=SimpleNamespace(id="account-1"), + user=SimpleNamespace(id="membership-1"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/src/api/cases.ts b/webui/src/api/cases.ts index 943dcf2..7f47a73 100644 --- a/webui/src/api/cases.ts +++ b/webui/src/api/cases.ts @@ -96,6 +96,33 @@ export type CaseTimelineEntry = { payload: Record; }; +export type FormalDecision = { + reference: InstitutionalReference; + temporal: { + revision: string; + valid_from?: string | null; + valid_to?: string | null; + recorded_at?: string | null; + change_reason?: string | null; + }; + decision_type: string; + state: string; + assurance_level: string; + operative_result?: string | null; + reasoning?: string | null; + conditions: string[]; + authority_context: Record; + delivery_refs: string[]; + remedy_refs: string[]; + review_refs: string[]; +}; + +export type CaseDecisionResult = { + case: CaseRecord; + decision: FormalDecision; + replayed: boolean; +}; + export function listCases( settings: ApiSettings, options: { @@ -147,6 +174,42 @@ export function caseTimeline( return apiFetch(settings, `/api/v1/cases/${encodeURIComponent(caseId)}/timeline`, { signal }); } +export function listCaseDecisions( + settings: ApiSettings, + caseId: string, + signal?: AbortSignal +): Promise<{ decisions: FormalDecision[] }> { + return apiFetch( + settings, + `/api/v1/cases/${encodeURIComponent(caseId)}/decisions`, + { signal } + ); +} + +export function recordCaseDecision( + settings: ApiSettings, + caseId: string, + payload: { + expected_case_revision: number; + effective_at: string; + decision_type: string; + operative_result: string; + reasoning: string; + conditions: string[]; + change_reason: string; + idempotency_key: string; + } +): Promise { + return apiFetch( + settings, + `/api/v1/cases/${encodeURIComponent(caseId)}/decisions`, + { + method: "POST", + body: JSON.stringify(payload) + } + ); +} + export function updateCase( settings: ApiSettings, caseId: string, diff --git a/webui/src/features/cases/CaseDecisionDialog.tsx b/webui/src/features/cases/CaseDecisionDialog.tsx new file mode 100644 index 0000000..b2634c8 --- /dev/null +++ b/webui/src/features/cases/CaseDecisionDialog.tsx @@ -0,0 +1,209 @@ +import { Scale } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Button, + ConfirmDialog, + Dialog, + DismissibleAlert, + DocumentationHelpLink, + FormField, + i18nMessage, + type ApiSettings +} from "@govoplan/core-webui"; +import { + recordCaseDecision, + type CaseDecisionResult, + type CaseRecord +} from "../../api/cases"; +import { CASES_FIELDS_DOCUMENTATION } from "./interfacePatterns"; + + +export default function CaseDecisionDialog({ + settings, + record, + open, + onClose, + onSaved +}: { + settings: ApiSettings; + record: CaseRecord; + open: boolean; + onClose: () => void; + onSaved: (result: CaseDecisionResult) => void; +}) { + const [decisionType, setDecisionType] = useState(""); + const [effectiveAt, setEffectiveAt] = useState(""); + const [operativeResult, setOperativeResult] = useState(""); + const [reasoning, setReasoning] = useState(""); + const [conditions, setConditions] = useState(""); + const [changeReason, setChangeReason] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [confirmOpen, setConfirmOpen] = useState(false); + const idempotencyKey = useRef(crypto.randomUUID()); + + useEffect(() => { + if (!open) return; + setDecisionType(defaultDecisionType(record)); + setEffectiveAt(dateTimeLocalValue(new Date())); + setOperativeResult(""); + setReasoning(""); + setConditions(""); + setChangeReason(""); + setError(""); + setConfirmOpen(false); + idempotencyKey.current = crypto.randomUUID(); + }, [open, record]); + + const disabledReason = useMemo(() => { + if (busy) return "The formal Decision is being recorded."; + if ( + !decisionType.trim() + || !effectiveAt + || !operativeResult.trim() + || !reasoning.trim() + || !changeReason.trim() + ) { + return "Complete the Decision type, effective time, result, reasoning, and change reason."; + } + return undefined; + }, [busy, changeReason, decisionType, effectiveAt, operativeResult, reasoning]); + + async function save() { + if (disabledReason) return; + setBusy(true); + setError(""); + try { + const result = await recordCaseDecision(settings, record.reference.object_id, { + expected_case_revision: record.revision, + effective_at: new Date(effectiveAt).toISOString(), + decision_type: decisionType.trim(), + operative_result: operativeResult.trim(), + reasoning: reasoning.trim(), + conditions: conditions.split("\n").map((item) => item.trim()).filter(Boolean), + change_reason: changeReason.trim(), + idempotency_key: idempotencyKey.current + }); + setConfirmOpen(false); + onSaved(result); + onClose(); + } catch (reason) { + setConfirmOpen(false); + setError(reason instanceof Error ? reason.message : "The formal Decision could not be recorded."); + } finally { + setBusy(false); + } + } + + return ( + <> + + + + + } + > +
+ + {error ? {error} : null} +

+ The server verifies your current acting assignment, the effective Mandate, the exact Case revision, evidence, and legal basis before recording the outcome. +

+
+ + setDecisionType(event.target.value)} + /> + + + setEffectiveAt(event.target.value)} + /> + +
+ +