diff --git a/docs/DECISIONS_DOMAIN.md b/docs/DECISIONS_DOMAIN.md index 7b406d6..4b3d5a7 100644 --- a/docs/DECISIONS_DOMAIN.md +++ b/docs/DECISIONS_DOMAIN.md @@ -29,3 +29,21 @@ Database restore is the persistence recovery unit. Requested effects remain separate from observed effects and keep audit/evidence references, allowing an operator to reconcile an outcome whose external effect was uncertain. Domain effect providers retain their own compensation and recovery behavior. + +## Records Filing + +Decisions publishes the optional `records.source.decisions` capability. It +resolves only an exact immutable `decision_revision` in the acting tenant and +requires both ordinary and protected Decision read authority because the +filing digest represents the complete formal outcome, including protected +reasoning and operative content. Records stores a bounded label, lifecycle +metadata, and SHA-256-bound source reference; Decisions remains authoritative +and current access is checked again whenever the source is reconstructed. + +## Search + +When Search is enabled, `decisions.decisions` indexes only bounded Decision +metadata, subject identifiers, legal-basis references, effect references, and +the linked Case route. Operative results, reasoning, and conditions are never +copied into the search index. Every result is tenant-bound and rechecked against +the current Decision read permission before disclosure. diff --git a/src/govoplan_decisions/backend/manifest.py b/src/govoplan_decisions/backend/manifest.py index 658cf59..b2def9f 100644 --- a/src/govoplan_decisions/backend/manifest.py +++ b/src/govoplan_decisions/backend/manifest.py @@ -3,12 +3,31 @@ from __future__ import annotations from pathlib import Path from govoplan_core.core.institutional import CAPABILITY_DECISION_REGISTRY -from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard -from govoplan_core.core.modules import CapabilityDocumentation, DocumentationLink, DocumentationTopic, MigrationSpec, ModuleContext, ModuleInterfaceProvider, ModuleManifest, PermissionDefinition, RoleTemplate +from govoplan_core.core.module_guards import ( + drop_table_retirement_provider, + persistent_table_uninstall_guard, +) +from govoplan_core.core.modules import ( + CapabilityDocumentation, + DocumentationLink, + DocumentationTopic, + MigrationSpec, + ModuleContext, + ModuleInterfaceProvider, + ModuleManifest, + PermissionDefinition, + RoleTemplate, +) from govoplan_core.core.provider_governance import declared_module_architecture +from govoplan_core.core.search import SearchSourceProviderRegistration from govoplan_core.db.base import Base from govoplan_decisions.backend.db import models as decision_models +from govoplan_decisions.backend.record_source import ( + CAPABILITY_RECORD_SOURCE_DECISIONS, + create_decisions_record_source, +) from govoplan_decisions.backend.service import SqlDecisionRegistry +from govoplan_decisions.backend.search_source import create_decisions_search_source MODULE_ID = "decisions" @@ -22,7 +41,16 @@ ADMIN_SCOPE = "decisions:decision:admin" def _permission(scope: str, label: str, description: str) -> PermissionDefinition: module_id, resource, action = scope.split(":", 2) - return PermissionDefinition(scope=scope, label=label, description=description, category="Decisions", level="tenant", module_id=module_id, resource=resource, action=action) + return PermissionDefinition( + scope=scope, + label=label, + description=description, + category="Decisions", + level="tenant", + module_id=module_id, + resource=resource, + action=action, + ) def _router(_context: ModuleContext): @@ -39,40 +67,121 @@ manifest = ModuleManifest( id=MODULE_ID, name=MODULE_NAME, version=MODULE_VERSION, - optional_dependencies=("mandates", "approvals", "committee", "cases", "audit", "policy", "records", "files", "postbox", "mail"), - provides_interfaces=(ModuleInterfaceProvider(name="decisions.formal_outcome", version="0.1.0"), ModuleInterfaceProvider(name="decisions.reconstruction", version="0.1.0")), + optional_dependencies=( + "mandates", + "approvals", + "committee", + "cases", + "audit", + "policy", + "records", + "files", + "postbox", + "mail", + ), + provides_interfaces=( + ModuleInterfaceProvider(name="decisions.formal_outcome", version="0.1.0"), + ModuleInterfaceProvider(name="decisions.reconstruction", version="0.1.0"), + ModuleInterfaceProvider( + name=CAPABILITY_RECORD_SOURCE_DECISIONS, version="1.0.0" + ), + ), permissions=( - _permission(READ_SCOPE, "View Decision metadata", "View formal Decision metadata and evidence references."), - _permission(SENSITIVE_READ_SCOPE, "View protected Decisions", "View protected reasoning, operative results, and conditions."), - _permission(WRITE_SCOPE, "Record Decisions", "Record governed formal outcomes and lifecycle revisions."), - _permission(ADMIN_SCOPE, "Administer Decisions", "Administer Decision access, lifecycle, and recovery."), + _permission( + READ_SCOPE, + "View Decision metadata", + "View formal Decision metadata and evidence references.", + ), + _permission( + SENSITIVE_READ_SCOPE, + "View protected Decisions", + "View protected reasoning, operative results, and conditions.", + ), + _permission( + WRITE_SCOPE, + "Record Decisions", + "Record governed formal outcomes and lifecycle revisions.", + ), + _permission( + ADMIN_SCOPE, + "Administer Decisions", + "Administer Decision access, lifecycle, and recovery.", + ), ), role_templates=( - RoleTemplate(slug="decision_officer", name="Decision officer", description="Record and inspect formal Decisions.", permissions=(READ_SCOPE, SENSITIVE_READ_SCOPE, WRITE_SCOPE)), - RoleTemplate(slug="decision_auditor", name="Decision auditor", description="Reconstruct protected formal outcomes.", permissions=(READ_SCOPE, SENSITIVE_READ_SCOPE)), + RoleTemplate( + slug="decision_officer", + name="Decision officer", + description="Record and inspect formal Decisions.", + permissions=(READ_SCOPE, SENSITIVE_READ_SCOPE, WRITE_SCOPE), + ), + RoleTemplate( + slug="decision_auditor", + name="Decision auditor", + description="Reconstruct protected formal outcomes.", + permissions=(READ_SCOPE, SENSITIVE_READ_SCOPE), + ), ), route_factory=_router, - capability_factories={CAPABILITY_DECISION_REGISTRY: _registry}, - capability_documentation={CAPABILITY_DECISION_REGISTRY: CapabilityDocumentation(label="Formal Decision registry", summary="Records and resolves immutable, reconstructable formal outcomes.", contract_version="0.1.0")}, + capability_factories={ + CAPABILITY_DECISION_REGISTRY: _registry, + CAPABILITY_RECORD_SOURCE_DECISIONS: create_decisions_record_source, + }, + capability_documentation={ + CAPABILITY_DECISION_REGISTRY: CapabilityDocumentation( + label="Formal Decision registry", + summary="Records and resolves immutable, reconstructable formal outcomes.", + contract_version="0.1.0", + ), + CAPABILITY_RECORD_SOURCE_DECISIONS: CapabilityDocumentation( + label="Formal Decision record source", + summary="Resolves complete, currently authorized immutable Decision revisions for Records filing.", + contract_version="1.0.0", + ), + }, migration_spec=MigrationSpec( module_id=MODULE_ID, metadata=Base.metadata, script_location=str(Path(__file__).with_name("migrations") / "versions"), retirement_supported=True, - retirement_provider=drop_table_retirement_provider(decision_models.FormalDecisionRevision, label="Decisions"), + retirement_provider=drop_table_retirement_provider( + decision_models.FormalDecisionRevision, label="Decisions" + ), retirement_notes="Destructive retirement requires a database snapshot and removes formal Decision history.", ), - uninstall_guard_providers=(persistent_table_uninstall_guard(decision_models.FormalDecisionRevision, label="Decisions"),), + uninstall_guard_providers=( + persistent_table_uninstall_guard( + decision_models.FormalDecisionRevision, label="Decisions" + ), + ), + search_sources=( + SearchSourceProviderRegistration( + id="decisions.decisions", + factory=create_decisions_search_source, + ), + ), documentation=( DocumentationTopic( id="decisions.formal-outcome", title="Formal institutional Decisions", summary="Reconstruct authority, evidence, reasoning, outcome, effects, and review history.", - body="Decisions preserves immutable formal outcomes. Corrections and revocations create linked revisions; requested and observed effects remain distinct for reconciliation. List and unversioned detail reads follow the titlebar valid-time and recorded-time selection; exact revision references remain exact and current authorization is unchanged.", + body="Decisions preserves immutable formal outcomes. Corrections and revocations create linked revisions; requested and observed effects remain distinct for reconciliation. List and unversioned detail reads follow the titlebar valid-time and recorded-time selection; exact revision references remain exact and current authorization is unchanged. When Records is enabled, filing resolves one exact complete Decision revision only after current metadata and protected-read permissions are rechecked; Records retains the digest-bound source reference while Decisions remains authoritative. When Search is enabled, Decisions contributes only rebuildable lifecycle, subject, legal-basis, effect, and linked-Case metadata. Operative results, reasoning, and conditions are excluded, and every result receives a current permission check before disclosure.", layer="configured", documentation_types=("admin", "user"), audience=("user", "operator", "module_admin", "auditor"), - links=(DocumentationLink(label="Decisions domain and recovery", href="govoplan-decisions/docs/DECISIONS_DOMAIN.md", kind="repository"),), + links=( + DocumentationLink( + label="Decisions domain and recovery", + href="govoplan-decisions/docs/DECISIONS_DOMAIN.md", + kind="repository", + ), + ), + metadata={ + "help_contexts": [ + "decisions.search.result", + "decisions.record.filing", + ], + }, ), ), architecture=declared_module_architecture( @@ -81,10 +190,21 @@ manifest = ModuleManifest( maturity="vertical_slice", documentation_ref="docs/DECISIONS_DOMAIN.md", test_ref="tests/test_decisions.py", - known_limits=("No dedicated Decision WebUI is included; consuming procedure modules present outcomes in context.",), + known_limits=( + "No dedicated Decision WebUI is included; consuming procedure modules present outcomes in context.", + ), supported_authority_modes=("native_authoritative",), - owned_concepts=("formal decision", "decision correction", "decision effect observation"), - non_owned_concepts=("approval gate", "committee deliberation", "effect execution", "record binary"), + owned_concepts=( + "formal decision", + "decision correction", + "decision effect observation", + ), + non_owned_concepts=( + "approval gate", + "committee deliberation", + "effect execution", + "record binary", + ), reference_packages=("product.service-to-decision",), migration_docs=("docs/DECISIONS_DOMAIN.md",), recovery_docs=("docs/DECISIONS_DOMAIN.md",), diff --git a/src/govoplan_decisions/backend/record_source.py b/src/govoplan_decisions/backend/record_source.py new file mode 100644 index 0000000..f8a6bd6 --- /dev/null +++ b/src/govoplan_decisions/backend/record_source.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from collections.abc import Sequence +import hashlib +import json + +from sqlalchemy.orm import Session + +from govoplan_core.core.institutional import InstitutionalReference +from govoplan_core.core.records import ( + RecordContractError, + RecordSourceLocator, + RecordSourceReference, +) +from govoplan_decisions.backend.db.models import FormalDecisionRevision +from govoplan_decisions.backend.service import SqlDecisionRegistry + + +CAPABILITY_RECORD_SOURCE_DECISIONS = "records.source.decisions" + + +class DecisionsRecordSource: + provider_id = "decisions" + + def resource_types(self) -> Sequence[str]: + return ("decision_revision",) + + def resolve( + self, + session: object, + principal: object, + *, + locator: RecordSourceLocator, + purpose: str, + ) -> RecordSourceReference: + if not isinstance(session, Session): + raise RecordContractError( + "Decision 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( + "Decision record references cannot cross tenants." + ) + if ( + locator.source_module != "decisions" + or locator.resource_type != "decision_revision" + ): + raise RecordContractError("Unsupported Decisions record source type.") + if not str(purpose or "").strip(): + raise RecordContractError("Decision record references require a purpose.") + if not _has(principal, "decisions:decision:read"): + raise RecordContractError("Current Decision read permission is required.") + if not ( + _has(principal, "decisions:decision:read_sensitive") + or _has(principal, "decisions:decision:admin") + ): + raise RecordContractError( + "Protected Decision read permission is required to file the complete formal outcome." + ) + row = ( + session.query(FormalDecisionRevision) + .filter( + FormalDecisionRevision.tenant_id == tenant_id, + FormalDecisionRevision.decision_id == locator.resource_id, + FormalDecisionRevision.revision == locator.source_revision, + ) + .one_or_none() + ) + if row is None: + raise RecordContractError("The exact Decision revision does not exist.") + decision = SqlDecisionRegistry().get_decision( + session, + principal, + reference=_decision_reference(row), + ) + if decision is None: + raise RecordContractError("The exact Decision revision is unavailable.") + snapshot_json = json.dumps( + row.payload, + sort_keys=True, + separators=(",", ":"), + default=str, + ).encode("utf-8") + return RecordSourceReference( + locator=locator, + label=f"Decision {decision.decision_type} - {decision.reference.object_id}", + authority_mode="external_authoritative", + content_sha256=hashlib.sha256(snapshot_json).hexdigest(), + content_type="application/vnd.govoplan.formal-decision-revision+json", + size_bytes=len(snapshot_json), + valid_from=decision.temporal.valid_from, + valid_to=decision.temporal.valid_to, + recorded_at=decision.temporal.recorded_at, + metadata={ + "decision_type": decision.decision_type, + "state": decision.state, + "assurance_level": decision.assurance_level, + "subject_count": len(decision.subject_refs), + "evidence_count": len(decision.fact_evidence), + "protected_snapshot": True, + "snapshot_sha256": hashlib.sha256(snapshot_json).hexdigest(), + }, + ) + + +def create_decisions_record_source(_context: object) -> DecisionsRecordSource: + return DecisionsRecordSource() + + +def _decision_reference(row: FormalDecisionRevision) -> InstitutionalReference: + return InstitutionalReference( + kind="decision", + owner_module="decisions", + object_id=row.decision_id, + tenant_id=row.tenant_id, + version=row.revision, + ) + + +def _has(principal: object, scope: str) -> bool: + return bool(hasattr(principal, "has") and principal.has(scope)) + + +__all__ = [ + "CAPABILITY_RECORD_SOURCE_DECISIONS", + "DecisionsRecordSource", + "create_decisions_record_source", +] diff --git a/src/govoplan_decisions/backend/search_source.py b/src/govoplan_decisions/backend/search_source.py new file mode 100644 index 0000000..f709c02 --- /dev/null +++ b/src/govoplan_decisions/backend/search_source.py @@ -0,0 +1,278 @@ +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_decisions.backend.db.models import FormalDecisionRevision + + +PROVIDER_ID = "decisions.decisions" +RESOURCE_TYPE = "formal_decision" +READ_SCOPE = "decisions:decision:read" +ADMIN_SCOPE = "decisions:decision:admin" + + +class DecisionsSearchSource: + def resource_types(self) -> Sequence[SearchResourceType]: + return ( + SearchResourceType( + provider_id=PROVIDER_ID, + module_id="decisions", + resource_type=RESOURCE_TYPE, + label="Formal Decisions", + 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(FormalDecisionRevision).where( + FormalDecisionRevision.tenant_id == request.tenant_id, + FormalDecisionRevision.superseded_at.is_(None), + ) + if request.cursor: + statement = statement.where( + FormalDecisionRevision.decision_id > request.cursor + ) + rows = list( + db.scalars( + statement.order_by(FormalDecisionRevision.decision_id).limit( + request.limit + 1 + ) + ) + ) + has_more = len(rows) > request.limit + selected = rows[: request.limit] + high_watermark = db.scalar( + select(func.max(FormalDecisionRevision.recorded_at)).where( + FormalDecisionRevision.tenant_id == request.tenant_id, + FormalDecisionRevision.superseded_at.is_(None), + ) + ) + return SearchBackfillPage( + documents=tuple(_document(row) for row in selected), + next_cursor=selected[-1].decision_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) or principal.has(ADMIN_SCOPE) + ): + return decisions + eligible = tuple( + item + for item in requests + if item.reference.tenant_id == principal.tenant_id + and item.reference.module_id == "decisions" + and item.reference.resource_type == RESOURCE_TYPE + ) + ids = {item.reference.resource_id for item in eligible} + available = ( + set( + _session(session).scalars( + select(FormalDecisionRevision.decision_id).where( + FormalDecisionRevision.tenant_id == principal.tenant_id, + FormalDecisionRevision.decision_id.in_(ids), + FormalDecisionRevision.superseded_at.is_(None), + ) + ) + ) + if ids + else set() + ) + for item in eligible: + decisions[item.reference.key] = item.reference.resource_id in available + return decisions + + def index_changes_for_event( + self, + session: object, + *, + event: PlatformEvent, + delivery_key: str, + ) -> Sequence[SearchIndexChange]: + if ( + event.module_id != "decisions" + or event.tenant is None + or event.resource is None + or event.resource.type != RESOURCE_TYPE + or event.resource.id is None + ): + return () + row = _session(session).scalar( + select(FormalDecisionRevision).where( + FormalDecisionRevision.tenant_id == event.tenant.id, + FormalDecisionRevision.decision_id == event.resource.id, + FormalDecisionRevision.superseded_at.is_(None), + ) + ) + cursor = event.event_id + document = _document(row, change_cursor=cursor) if row is not None else None + reference = SearchResourceReference( + tenant_id=event.tenant.id, + module_id="decisions", + resource_type=RESOURCE_TYPE, + resource_id=event.resource.id, + ) + return ( + SearchIndexChange( + change_id=f"{delivery_key}:{PROVIDER_ID}", + provider_id=PROVIDER_ID, + kind="upsert" if document is not None else "delete", + reference=reference, + source_revision=(document.source_revision if document else cursor), + cursor=cursor, + document=document, + occurred_at=event.occurred_at, + ), + ) + + +def create_decisions_search_source( + _context: ModuleContext, +) -> DecisionsSearchSource: + return DecisionsSearchSource() + + +def _document( + row: FormalDecisionRevision, + *, + change_cursor: str | None = None, +) -> SearchDocument: + payload = dict(row.payload or {}) + authority = _mapping(payload.get("authority_context")) + case_ref = _mapping(authority.get("case_ref")) + case_id = _text(case_ref.get("object_id")) + subject_ids = tuple( + value + for item in _mapping_sequence(payload.get("subject_refs")) + if (value := _text(item.get("object_id"))) + ) + legal_basis_keys = tuple( + ":".join( + value + for value in ( + _text(item.get("authority")), + _text(item.get("reference")), + _text(item.get("version")), + ) + if value + ) + for item in _mapping_sequence(payload.get("legal_bases")) + ) + body = " ".join( + ( + *subject_ids, + *legal_basis_keys, + *tuple(_text_sequence(payload.get("delivery_refs"))), + *tuple(_text_sequence(payload.get("remedy_refs"))), + *tuple(_text_sequence(payload.get("review_refs"))), + ) + ) + url = ( + f"/cases/{quote(case_id, safe='')}" + if case_id + else f"/cases?decisionId={quote(row.decision_id, safe='')}" + ) + return SearchDocument( + tenant_id=row.tenant_id, + module_id="decisions", + provider_id=PROVIDER_ID, + resource_type=RESOURCE_TYPE, + resource_id=row.decision_id, + title=f"{_humanize(row.decision_type)} Decision", + url=url, + summary=f"{_humanize(row.state)} - revision {row.revision}", + body=body[:200_000] or None, + keywords=tuple( + value[:200] + for value in (row.decision_type, row.state, case_id or "") + if value + ), + visibility="restricted", + acl_tokens=(f"scope:{READ_SCOPE}", f"scope:{ADMIN_SCOPE}"), + metadata={ + "decision_type": row.decision_type, + "state": row.state, + "case_id": case_id, + "protected_content_indexed": False, + }, + source_revision=row.revision, + change_cursor=change_cursor, + source_updated_at=row.recorded_at, + requires_authorization_recheck=True, + ) + + +def _mapping(value: object) -> dict[str, object]: + return dict(value) if isinstance(value, Mapping) else {} + + +def _mapping_sequence(value: object) -> tuple[dict[str, object], ...]: + if not isinstance(value, (list, tuple)): + return () + return tuple(_mapping(item) for item in value if isinstance(item, Mapping)) + + +def _text_sequence(value: object) -> tuple[str, ...]: + if not isinstance(value, (list, tuple)): + return () + return tuple(item for item in (_text(item) for item in value) if item) + + +def _text(value: object) -> str | None: + normalized = str(value or "").strip() + return normalized or None + + +def _humanize(value: str) -> str: + return " ".join(part.capitalize() for part in value.replace("_", " ").split()) + + +def _assert_source(provider_id: str, resource_type: str) -> None: + if provider_id != PROVIDER_ID or resource_type != RESOURCE_TYPE: + raise ValueError("Unsupported Decisions search source.") + + +def _session(value: object) -> Session: + if not isinstance(value, Session): + raise TypeError("Decisions search requires a SQLAlchemy session.") + return value + + +__all__ = [ + "DecisionsSearchSource", + "PROVIDER_ID", + "RESOURCE_TYPE", + "create_decisions_search_source", +] diff --git a/src/govoplan_decisions/backend/service.py b/src/govoplan_decisions/backend/service.py index 741adb0..57670bf 100644 --- a/src/govoplan_decisions/backend/service.py +++ b/src/govoplan_decisions/backend/service.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import UTC, datetime from typing import Any, Mapping +import uuid from sqlalchemy.orm import Session @@ -12,6 +13,13 @@ from govoplan_core.core.institutional import ( TemporalRevision, revise_formal_decision, ) +from govoplan_core.core.events import ( + EventActorRef, + EventObjectRef, + EventTenantRef, + PlatformEvent, + emit_platform_event, +) from govoplan_core.db.temporal import apply_temporal_revision_filter from govoplan_decisions.backend.db.models import FormalDecisionRevision @@ -127,6 +135,33 @@ class SqlDecisionRegistry: ) typed_session.add(row) typed_session.flush() + emit_platform_event( + typed_session, + PlatformEvent( + event_id=str(uuid.uuid4()), + type="decisions.decision.recorded", + module_id="decisions", + payload={ + "decision_type": decision.decision_type, + "state": decision.state, + "revision": decision.temporal.revision, + "assurance_level": decision.assurance_level, + "subject_count": len(decision.subject_refs), + "requested_effect_count": len(decision.requested_effects), + "observed_effect_count": len(decision.observed_effects), + }, + occurred_at=_recorded_at(decision.temporal), + actor=EventActorRef(type="account", id=_principal_actor(principal)), + tenant=EventTenantRef(id=tenant_id), + resource=EventObjectRef( + type="formal_decision", + id=decision.reference.object_id, + label=f"{decision.decision_type} Decision", + ), + classification="restricted", + institutional_context=decision.authority_context, + ), + ) return _decision_from_row(row) diff --git a/tests/test_decisions.py b/tests/test_decisions.py index 799a1b4..02f6944 100644 --- a/tests/test_decisions.py +++ b/tests/test_decisions.py @@ -22,7 +22,9 @@ from govoplan_core.core.temporal import ( bind_temporal_data_context, reset_temporal_data_context, ) +from govoplan_core.core.records import RecordContractError, RecordSourceLocator from govoplan_decisions.backend.db.models import FormalDecisionRevision +from govoplan_decisions.backend.record_source import DecisionsRecordSource from govoplan_decisions.backend.service import ( DecisionStoreError, SqlDecisionRegistry, @@ -37,22 +39,57 @@ NOW = datetime(2026, 8, 1, 12, 0, tzinfo=UTC) class Principal: tenant_id: str = "tenant-1" account_id: str = "account-1" + scopes: tuple[str, ...] = ( + "decisions:decision:read", + "decisions:decision:read_sensitive", + ) + + def has(self, scope: str) -> bool: + return scope in self.scopes -def reference(kind: str, object_id: str, owner: str, version: str | None = "1") -> InstitutionalReference: - return InstitutionalReference(kind=kind, owner_module=owner, object_id=object_id, tenant_id="tenant-1", version=version, valid_at=NOW) # type: ignore[arg-type] +def reference( + kind: str, object_id: str, owner: str, version: str | None = "1" +) -> InstitutionalReference: + return InstitutionalReference( + kind=kind, + owner_module=owner, + object_id=object_id, + tenant_id="tenant-1", + version=version, + valid_at=NOW, + ) # type: ignore[arg-type] def decision() -> FormalDecision: - temporal = TemporalRevision(revision="1", valid_from=NOW, recorded_at=NOW, change_reason="Decision accepted.") + temporal = TemporalRevision( + revision="1", + valid_from=NOW, + recorded_at=NOW, + change_reason="Decision accepted.", + ) decision_ref = reference("decision", "decision-1", "decisions") mandate_ref = reference("mandate", "mandate-1", "mandates", "7") - evidence = EvidenceReference(kind="record", owner_module="committee", evidence_id="minutes-1", tenant_id="tenant-1", version="1", captured_at=NOW) - basis = LegalBasisReference(kind="statute", authority="Council", reference="rules:12", version="2026") + evidence = EvidenceReference( + kind="record", + owner_module="committee", + evidence_id="minutes-1", + tenant_id="tenant-1", + version="1", + captured_at=NOW, + ) + basis = LegalBasisReference( + kind="statute", authority="Council", reference="rules:12", version="2026" + ) context = GovernedContextEnvelope( tenant_id="tenant-1", temporal=temporal, - actor=ActorRepresentationReference(tenant_id="tenant-1", account_id="account-1", identity_id="identity-1", mandate_ref=mandate_ref), + actor=ActorRepresentationReference( + tenant_id="tenant-1", + account_id="account-1", + identity_id="identity-1", + mandate_ref=mandate_ref, + ), mandate_ref=mandate_ref, decision_ref=decision_ref, legal_bases=(basis,), @@ -85,29 +122,64 @@ class DecisionTests(unittest.TestCase): def test_record_replay_revise_and_exact_history(self) -> None: registry = SqlDecisionRegistry() - first = registry.record_decision(self.session, self.principal, decision=decision()) - self.assertEqual(first, registry.record_decision(self.session, self.principal, decision=decision())) - temporal = TemporalRevision(revision="2", valid_from=NOW, recorded_at=NOW + timedelta(minutes=1), change_reason="Effect confirmed.") - revised = revise_formal_decision(first, expected_revision="1", temporal=temporal, state="effective") - registry.record_decision(self.session, self.principal, decision=revised, expected_revision="1") - current = registry.get_decision(self.session, self.principal, reference=reference("decision", "decision-1", "decisions", None)) - historical = registry.get_decision(self.session, self.principal, reference=first.reference) + first = registry.record_decision( + self.session, self.principal, decision=decision() + ) + self.assertEqual( + first, + registry.record_decision(self.session, self.principal, decision=decision()), + ) + temporal = TemporalRevision( + revision="2", + valid_from=NOW, + recorded_at=NOW + timedelta(minutes=1), + change_reason="Effect confirmed.", + ) + revised = revise_formal_decision( + first, expected_revision="1", temporal=temporal, state="effective" + ) + registry.record_decision( + self.session, self.principal, decision=revised, expected_revision="1" + ) + current = registry.get_decision( + self.session, + self.principal, + reference=reference("decision", "decision-1", "decisions", None), + ) + historical = registry.get_decision( + self.session, self.principal, reference=first.reference + ) self.assertEqual("2", current.temporal.revision if current else None) self.assertEqual("1", historical.temporal.revision if historical else None) def test_occ_and_tenant_isolation(self) -> None: registry = SqlDecisionRegistry() - first = registry.record_decision(self.session, self.principal, decision=decision()) - temporal = TemporalRevision(revision="2", valid_from=NOW, recorded_at=NOW + timedelta(minutes=1), change_reason="Effect confirmed.") - revised = revise_formal_decision(first, expected_revision="1", temporal=temporal, state="effective") + first = registry.record_decision( + self.session, self.principal, decision=decision() + ) + temporal = TemporalRevision( + revision="2", + valid_from=NOW, + recorded_at=NOW + timedelta(minutes=1), + change_reason="Effect confirmed.", + ) + revised = revise_formal_decision( + first, expected_revision="1", temporal=temporal, state="effective" + ) with self.assertRaisesRegex(DecisionStoreError, "stale"): - registry.record_decision(self.session, self.principal, decision=revised, expected_revision="0") + registry.record_decision( + self.session, self.principal, decision=revised, expected_revision="0" + ) with self.assertRaisesRegex(Exception, "same-tenant"): - registry.get_decision(self.session, Principal("tenant-2"), reference=first.reference) + registry.get_decision( + self.session, Principal("tenant-2"), reference=first.reference + ) def test_temporal_context_selects_valid_and_recorded_state(self) -> None: registry = SqlDecisionRegistry() - first = registry.record_decision(self.session, self.principal, decision=decision()) + first = registry.record_decision( + self.session, self.principal, decision=decision() + ) temporal = TemporalRevision( revision="2", valid_from=NOW, @@ -161,6 +233,39 @@ class DecisionTests(unittest.TestCase): finally: reset_temporal_data_context(token) + def test_record_source_requires_protected_read_and_hashes_exact_revision( + self, + ) -> None: + SqlDecisionRegistry().record_decision( + self.session, + self.principal, + decision=decision(), + ) + locator = RecordSourceLocator( + tenant_id="tenant-1", + source_module="decisions", + resource_type="decision_revision", + resource_id="decision-1", + source_revision="1", + ) + result = DecisionsRecordSource().resolve( + self.session, + self.principal, + locator=locator, + purpose="file formal outcome", + ) + self.assertEqual(64, len(result.content_sha256 or "")) + self.assertEqual("decided", result.metadata["state"]) + self.assertTrue(result.metadata["protected_snapshot"]) + + with self.assertRaisesRegex(RecordContractError, "Protected Decision"): + DecisionsRecordSource().resolve( + self.session, + Principal(scopes=("decisions:decision:read",)), + locator=locator, + purpose="file formal outcome", + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_search_source.py b/tests/test_search_source.py new file mode 100644 index 0000000..a12d575 --- /dev/null +++ b/tests/test_search_source.py @@ -0,0 +1,133 @@ +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.search import ( + SearchAuthorizationRequest, + SearchBackfillRequest, + SearchResourceReference, +) +from govoplan_decisions.backend.db.models import FormalDecisionRevision +from govoplan_decisions.backend.search_source import DecisionsSearchSource, PROVIDER_ID + + +NOW = datetime(2026, 8, 6, 11, 0, tzinfo=UTC) + + +class DecisionsSearchSourceTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite+pysqlite:///:memory:") + FormalDecisionRevision.__table__.create(self.engine) + self.session = Session(self.engine) + self.session.add( + FormalDecisionRevision( + tenant_id="tenant-1", + decision_id="decision-1", + revision="1", + decision_type="permit", + state="decided", + valid_from=NOW, + recorded_at=NOW, + payload={ + "operative_result": "PROTECTED-RESULT", + "reasoning": "PROTECTED-REASONING", + "conditions": ["PROTECTED-CONDITION"], + "subject_refs": [ + {"kind": "party", "owner_module": "parties", "object_id": "applicant-1"} + ], + "legal_bases": [ + {"authority": "Example", "reference": "law:3", "version": "2026"} + ], + "authority_context": { + "case_ref": {"object_id": "case-1"} + }, + "delivery_refs": ["postbox:delivery-1"], + "remedy_refs": ["remedy:appeal"], + "review_refs": [], + }, + created_by="account-1", + ) + ) + self.session.commit() + self.source = DecisionsSearchSource() + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def test_search_projection_excludes_protected_decision_content(self) -> None: + page = self.source.backfill( + self.session, + request=SearchBackfillRequest( + tenant_id="tenant-1", + provider_id=PROVIDER_ID, + resource_type="formal_decision", + rebuild_id="rebuild-1", + ), + ) + document = page.documents[0] + serialized = repr( + (document.title, document.summary, document.body, document.keywords, document.metadata) + ) + self.assertNotIn("PROTECTED-", serialized) + self.assertEqual("/cases/case-1", document.url) + self.assertFalse(document.metadata["protected_content_indexed"]) + + reference = SearchResourceReference( + tenant_id="tenant-1", + module_id="decisions", + resource_type="formal_decision", + resource_id="decision-1", + ) + request = SearchAuthorizationRequest(reference=reference, source_revision="1") + self.assertTrue( + self.source.authorize( + self.session, + _principal({"decisions:decision: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="decisions.decision.recorded", + module_id="decisions", + tenant=EventTenantRef(id="tenant-1"), + resource=EventObjectRef(type="formal_decision", id="decision-1"), + ), + delivery_key="delivery-1", + ) + self.assertEqual("upsert", changes[0].kind) + + +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()