Expose decision filing and search sources

This commit is contained in:
2026-08-06 12:42:20 +02:00
parent 8719fd7258
commit 3845e068de
7 changed files with 857 additions and 39 deletions
+18
View File
@@ -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 separate from observed effects and keep audit/evidence references, allowing an
operator to reconcile an outcome whose external effect was uncertain. Domain operator to reconcile an outcome whose external effect was uncertain. Domain
effect providers retain their own compensation and recovery behavior. 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.
+140 -20
View File
@@ -3,12 +3,31 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from govoplan_core.core.institutional import CAPABILITY_DECISION_REGISTRY 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.module_guards import (
from govoplan_core.core.modules import CapabilityDocumentation, DocumentationLink, DocumentationTopic, MigrationSpec, ModuleContext, ModuleInterfaceProvider, ModuleManifest, PermissionDefinition, RoleTemplate 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.provider_governance import declared_module_architecture
from govoplan_core.core.search import SearchSourceProviderRegistration
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_decisions.backend.db import models as decision_models 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.service import SqlDecisionRegistry
from govoplan_decisions.backend.search_source import create_decisions_search_source
MODULE_ID = "decisions" MODULE_ID = "decisions"
@@ -22,7 +41,16 @@ ADMIN_SCOPE = "decisions:decision:admin"
def _permission(scope: str, label: str, description: str) -> PermissionDefinition: def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2) 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): def _router(_context: ModuleContext):
@@ -39,40 +67,121 @@ manifest = ModuleManifest(
id=MODULE_ID, id=MODULE_ID,
name=MODULE_NAME, name=MODULE_NAME,
version=MODULE_VERSION, version=MODULE_VERSION,
optional_dependencies=("mandates", "approvals", "committee", "cases", "audit", "policy", "records", "files", "postbox", "mail"), optional_dependencies=(
provides_interfaces=(ModuleInterfaceProvider(name="decisions.formal_outcome", version="0.1.0"), ModuleInterfaceProvider(name="decisions.reconstruction", version="0.1.0")), "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=( permissions=(
_permission(READ_SCOPE, "View Decision metadata", "View formal Decision metadata and evidence references."), _permission(
_permission(SENSITIVE_READ_SCOPE, "View protected Decisions", "View protected reasoning, operative results, and conditions."), READ_SCOPE,
_permission(WRITE_SCOPE, "Record Decisions", "Record governed formal outcomes and lifecycle revisions."), "View Decision metadata",
_permission(ADMIN_SCOPE, "Administer Decisions", "Administer Decision access, lifecycle, and recovery."), "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=( role_templates=(
RoleTemplate(slug="decision_officer", name="Decision officer", description="Record and inspect formal Decisions.", permissions=(READ_SCOPE, SENSITIVE_READ_SCOPE, WRITE_SCOPE)), RoleTemplate(
RoleTemplate(slug="decision_auditor", name="Decision auditor", description="Reconstruct protected formal outcomes.", permissions=(READ_SCOPE, SENSITIVE_READ_SCOPE)), 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, route_factory=_router,
capability_factories={CAPABILITY_DECISION_REGISTRY: _registry}, capability_factories={
capability_documentation={CAPABILITY_DECISION_REGISTRY: CapabilityDocumentation(label="Formal Decision registry", summary="Records and resolves immutable, reconstructable formal outcomes.", contract_version="0.1.0")}, 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( migration_spec=MigrationSpec(
module_id=MODULE_ID, module_id=MODULE_ID,
metadata=Base.metadata, metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"), script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True, 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.", 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=( documentation=(
DocumentationTopic( DocumentationTopic(
id="decisions.formal-outcome", id="decisions.formal-outcome",
title="Formal institutional Decisions", title="Formal institutional Decisions",
summary="Reconstruct authority, evidence, reasoning, outcome, effects, and review history.", 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", layer="configured",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "auditor"), 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( architecture=declared_module_architecture(
@@ -81,10 +190,21 @@ manifest = ModuleManifest(
maturity="vertical_slice", maturity="vertical_slice",
documentation_ref="docs/DECISIONS_DOMAIN.md", documentation_ref="docs/DECISIONS_DOMAIN.md",
test_ref="tests/test_decisions.py", 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",), supported_authority_modes=("native_authoritative",),
owned_concepts=("formal decision", "decision correction", "decision effect observation"), owned_concepts=(
non_owned_concepts=("approval gate", "committee deliberation", "effect execution", "record binary"), "formal decision",
"decision correction",
"decision effect observation",
),
non_owned_concepts=(
"approval gate",
"committee deliberation",
"effect execution",
"record binary",
),
reference_packages=("product.service-to-decision",), reference_packages=("product.service-to-decision",),
migration_docs=("docs/DECISIONS_DOMAIN.md",), migration_docs=("docs/DECISIONS_DOMAIN.md",),
recovery_docs=("docs/DECISIONS_DOMAIN.md",), recovery_docs=("docs/DECISIONS_DOMAIN.md",),
@@ -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",
]
@@ -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",
]
+35
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any, Mapping from typing import Any, Mapping
import uuid
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -12,6 +13,13 @@ from govoplan_core.core.institutional import (
TemporalRevision, TemporalRevision,
revise_formal_decision, 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_core.db.temporal import apply_temporal_revision_filter
from govoplan_decisions.backend.db.models import FormalDecisionRevision from govoplan_decisions.backend.db.models import FormalDecisionRevision
@@ -127,6 +135,33 @@ class SqlDecisionRegistry:
) )
typed_session.add(row) typed_session.add(row)
typed_session.flush() 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) return _decision_from_row(row)
+124 -19
View File
@@ -22,7 +22,9 @@ from govoplan_core.core.temporal import (
bind_temporal_data_context, bind_temporal_data_context,
reset_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.db.models import FormalDecisionRevision
from govoplan_decisions.backend.record_source import DecisionsRecordSource
from govoplan_decisions.backend.service import ( from govoplan_decisions.backend.service import (
DecisionStoreError, DecisionStoreError,
SqlDecisionRegistry, SqlDecisionRegistry,
@@ -37,22 +39,57 @@ NOW = datetime(2026, 8, 1, 12, 0, tzinfo=UTC)
class Principal: class Principal:
tenant_id: str = "tenant-1" tenant_id: str = "tenant-1"
account_id: str = "account-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: def reference(
return InstitutionalReference(kind=kind, owner_module=owner, object_id=object_id, tenant_id="tenant-1", version=version, valid_at=NOW) # type: ignore[arg-type] 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: 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") decision_ref = reference("decision", "decision-1", "decisions")
mandate_ref = reference("mandate", "mandate-1", "mandates", "7") 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) evidence = EvidenceReference(
basis = LegalBasisReference(kind="statute", authority="Council", reference="rules:12", version="2026") 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( context = GovernedContextEnvelope(
tenant_id="tenant-1", tenant_id="tenant-1",
temporal=temporal, 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, mandate_ref=mandate_ref,
decision_ref=decision_ref, decision_ref=decision_ref,
legal_bases=(basis,), legal_bases=(basis,),
@@ -85,29 +122,64 @@ class DecisionTests(unittest.TestCase):
def test_record_replay_revise_and_exact_history(self) -> None: def test_record_replay_revise_and_exact_history(self) -> None:
registry = SqlDecisionRegistry() registry = SqlDecisionRegistry()
first = registry.record_decision(self.session, self.principal, decision=decision()) first = registry.record_decision(
self.assertEqual(first, registry.record_decision(self.session, self.principal, decision=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") self.assertEqual(
registry.record_decision(self.session, self.principal, decision=revised, expected_revision="1") first,
current = registry.get_decision(self.session, self.principal, reference=reference("decision", "decision-1", "decisions", None)) registry.record_decision(self.session, self.principal, decision=decision()),
historical = registry.get_decision(self.session, self.principal, reference=first.reference) )
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("2", current.temporal.revision if current else None)
self.assertEqual("1", historical.temporal.revision if historical else None) self.assertEqual("1", historical.temporal.revision if historical else None)
def test_occ_and_tenant_isolation(self) -> None: def test_occ_and_tenant_isolation(self) -> None:
registry = SqlDecisionRegistry() registry = SqlDecisionRegistry()
first = registry.record_decision(self.session, self.principal, decision=decision()) first = registry.record_decision(
temporal = TemporalRevision(revision="2", valid_from=NOW, recorded_at=NOW + timedelta(minutes=1), change_reason="Effect confirmed.") self.session, self.principal, decision=decision()
revised = revise_formal_decision(first, expected_revision="1", temporal=temporal, state="effective") )
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"): 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"): 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: def test_temporal_context_selects_valid_and_recorded_state(self) -> None:
registry = SqlDecisionRegistry() registry = SqlDecisionRegistry()
first = registry.record_decision(self.session, self.principal, decision=decision()) first = registry.record_decision(
self.session, self.principal, decision=decision()
)
temporal = TemporalRevision( temporal = TemporalRevision(
revision="2", revision="2",
valid_from=NOW, valid_from=NOW,
@@ -161,6 +233,39 @@ class DecisionTests(unittest.TestCase):
finally: finally:
reset_temporal_data_context(token) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+133
View File
@@ -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()