feat: implement institutional mandate resolver

This commit is contained in:
2026-08-01 17:48:37 +02:00
commit 8880818045
24 changed files with 1469 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""GovOPlaN institutional mandates module."""
__version__ = "0.1.14"
@@ -0,0 +1 @@
"""Backend package for GovOPlaN Mandates."""
@@ -0,0 +1 @@
"""Database models owned by GovOPlaN Mandates."""
@@ -0,0 +1,59 @@
from __future__ import annotations
from datetime import datetime
import uuid
from typing import Any
from sqlalchemy import DateTime, ForeignKey, Index, JSON, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class MandateRevision(Base, TimestampMixin):
__tablename__ = "mandate_revisions"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"mandate_id",
"revision",
name="uq_mandate_revision",
),
Index(
"ix_mandate_current",
"tenant_id",
"mandate_id",
"superseded_at",
),
Index(
"ix_mandate_resolution",
"tenant_id",
"status",
"valid_from",
"valid_to",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
mandate_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
revision: Mapped[str] = mapped_column(String(120), nullable=False)
previous_revision_id: Mapped[str | None] = mapped_column(
ForeignKey("mandate_revisions.id", ondelete="RESTRICT"),
nullable=True,
index=True,
)
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
valid_to: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
superseded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
created_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
__all__ = ["MandateRevision"]
+166
View File
@@ -0,0 +1,166 @@
from __future__ import annotations
from pathlib import Path
from govoplan_core.core.institutional import CAPABILITY_MANDATE_RESOLVER
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.db.base import Base
from govoplan_mandates.backend.db import models as mandate_models
from govoplan_mandates.backend.service import SqlMandateResolver
MODULE_ID = "mandates"
MODULE_NAME = "Mandates"
MODULE_VERSION = "0.1.14"
READ_SCOPE = "mandates:definition:read"
WRITE_SCOPE = "mandates:definition:write"
ADMIN_SCOPE = "mandates:definition: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="Mandates",
level="tenant",
module_id=module_id,
resource=resource,
action=action,
)
def _router(_context: ModuleContext):
from govoplan_mandates.backend.router import router
return router
def _resolver(_context: ModuleContext) -> SqlMandateResolver:
return SqlMandateResolver()
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
version=MODULE_VERSION,
optional_dependencies=("organizations", "idm", "access", "policy", "audit"),
provides_interfaces=(
ModuleInterfaceProvider(name="mandates.definition", version="0.1.0"),
ModuleInterfaceProvider(name="mandates.resolution", version="0.1.0"),
),
permissions=(
_permission(READ_SCOPE, "View mandates", "View mandate definitions, history, and resolution evidence."),
_permission(WRITE_SCOPE, "Manage mandates", "Create and revise mandate definitions."),
_permission(ADMIN_SCOPE, "Administer mandates", "Administer mandate lifecycle and recovery."),
),
role_templates=(
RoleTemplate(
slug="mandate_manager",
name="Mandate manager",
description="Manage institutional mandate and jurisdiction definitions.",
permissions=(READ_SCOPE, WRITE_SCOPE),
),
RoleTemplate(
slug="mandate_reader",
name="Mandate reader",
description="Inspect mandate definitions and resolution evidence.",
permissions=(READ_SCOPE,),
),
),
route_factory=_router,
capability_factories={CAPABILITY_MANDATE_RESOLVER: _resolver},
capability_documentation={
CAPABILITY_MANDATE_RESOLVER: CapabilityDocumentation(
label="Mandate resolver",
summary="Resolves effective institutional competence deterministically and fail-closed.",
contract_version="0.1.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(
mandate_models.MandateRevision,
label="Mandates",
),
retirement_notes="Destructive retirement requires a database snapshot and removes immutable Mandate history.",
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
mandate_models.MandateRevision,
label="Mandates",
),
),
documentation=(
DocumentationTopic(
id="mandates.definition-and-resolution",
title="Institutional mandates",
summary="Define and resolve effective authority, jurisdiction, legal basis, and evidence.",
body=(
"Mandates stores immutable revisions and resolves the one effective authority for a task. "
"Conflicting or missing authority fails closed. Consequential consumers retain the exact revision and evidence."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "auditor"),
links=(
DocumentationLink(
label="Mandates domain and recovery",
href="govoplan-mandates/docs/MANDATES_DOMAIN.md",
kind="repository",
),
),
),
),
architecture=declared_module_architecture(
layer="institutional_foundation",
kind="domain",
maturity="vertical_slice",
documentation_ref="docs/MANDATES_DOMAIN.md",
test_ref="tests/test_mandates.py",
known_limits=("No dedicated WebUI is included; administration is API-first.",),
supported_authority_modes=("native_authoritative",),
owned_concepts=("mandate", "jurisdiction authority", "competence history"),
non_owned_concepts=("organization structure", "function incumbency", "application permission", "formal decision"),
reference_packages=("product.service-to-decision",),
migration_docs=("docs/MANDATES_DOMAIN.md",),
recovery_docs=("docs/MANDATES_DOMAIN.md",),
security_docs=("docs/MANDATES_DOMAIN.md",),
operations_docs=("docs/MANDATES_DOMAIN.md",),
),
)
def get_manifest() -> ModuleManifest:
return manifest
__all__ = [
"ADMIN_SCOPE",
"MODULE_ID",
"MODULE_NAME",
"MODULE_VERSION",
"READ_SCOPE",
"WRITE_SCOPE",
"get_manifest",
"manifest",
]
@@ -0,0 +1 @@
"""Mandates database migrations."""
@@ -0,0 +1 @@
"""Mandates migration revisions."""
@@ -0,0 +1,51 @@
"""v0.1.14 Mandates baseline.
Revision ID: a8b1c2d3e4f5
Revises: None
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "a8b1c2d3e4f5"
down_revision = None
branch_labels = None
depends_on = "4f2a9c8e7b6d"
def upgrade() -> None:
op.create_table(
"mandate_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("mandate_id", sa.String(length=255), nullable=False),
sa.Column("revision", sa.String(length=120), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["previous_revision_id"],
["mandate_revisions.id"],
name=op.f("fk_mandate_revisions_previous_revision_id_mandate_revisions"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_mandate_revisions")),
sa.UniqueConstraint("tenant_id", "mandate_id", "revision", name="uq_mandate_revision"),
)
for column in ("tenant_id", "mandate_id", "previous_revision_id", "status", "recorded_at", "superseded_at", "created_by"):
op.create_index(op.f(f"ix_mandate_revisions_{column}"), "mandate_revisions", [column], unique=False)
op.create_index("ix_mandate_current", "mandate_revisions", ["tenant_id", "mandate_id", "superseded_at"], unique=False)
op.create_index("ix_mandate_resolution", "mandate_revisions", ["tenant_id", "status", "valid_from", "valid_to"], unique=False)
def downgrade() -> None:
op.drop_table("mandate_revisions")
+129
View File
@@ -0,0 +1,129 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.institutional import InstitutionalContextError
from govoplan_core.db.session import get_session
from govoplan_mandates.backend.manifest import READ_SCOPE, WRITE_SCOPE
from govoplan_mandates.backend.schemas import (
MandateListResponse,
MandateResolutionPayload,
MandateWriteRequest,
)
from govoplan_mandates.backend.service import (
MandateStoreError,
SqlMandateResolver,
definition_from_mapping,
get_mandate,
list_mandates,
record_mandate,
resolution_request_from_mapping,
)
router = APIRouter(prefix="/mandates", tags=["mandates"])
def _require_scope(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing scope: {scope}",
)
def _error(exc: Exception) -> HTTPException:
message = str(exc)
code = status.HTTP_409_CONFLICT if "conflict" in message.lower() else status.HTTP_400_BAD_REQUEST
return HTTPException(status_code=code, detail=message)
@router.get("/definitions", response_model=MandateListResponse)
def api_list_mandates(
mandate_status: str | None = Query(default=None, alias="status"),
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> MandateListResponse:
_require_scope(principal, READ_SCOPE)
try:
items = list_mandates(
session,
principal,
status=mandate_status,
limit=limit,
)
except (MandateStoreError, InstitutionalContextError) as exc:
raise _error(exc) from exc
return MandateListResponse(
mandates=[item.to_dict(include_inspection=True) for item in items]
)
@router.get("/definitions/{mandate_id}", response_model=dict[str, Any])
def api_get_mandate(
mandate_id: str,
revision: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require_scope(principal, READ_SCOPE)
item = get_mandate(
session,
principal,
mandate_id=mandate_id,
revision=revision,
)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mandate not found")
return item.to_dict(include_inspection=True)
@router.post(
"/definitions",
response_model=dict[str, Any],
status_code=status.HTTP_201_CREATED,
)
def api_record_mandate(
payload: MandateWriteRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require_scope(principal, WRITE_SCOPE)
try:
item = record_mandate(
session,
principal,
definition=definition_from_mapping(payload.definition),
expected_revision=payload.expected_revision,
)
session.commit()
except (MandateStoreError, InstitutionalContextError) as exc:
session.rollback()
raise _error(exc) from exc
return item.to_dict(include_inspection=True)
@router.post("/resolve", response_model=dict[str, Any])
def api_resolve_mandate(
payload: MandateResolutionPayload,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require_scope(principal, READ_SCOPE)
try:
result = SqlMandateResolver().resolve_mandate(
session,
principal,
request=resolution_request_from_mapping(payload.request),
)
except (MandateStoreError, InstitutionalContextError) as exc:
raise _error(exc) from exc
return result.to_dict(include_inspection=True)
__all__ = ["router"]
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class MandateWriteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
definition: dict[str, Any]
expected_revision: str | None = Field(default=None, max_length=120)
class MandateResolutionPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
request: dict[str, Any]
class MandateListResponse(BaseModel):
mandates: list[dict[str, Any]]
__all__ = [
"MandateListResponse",
"MandateResolutionPayload",
"MandateWriteRequest",
]
+307
View File
@@ -0,0 +1,307 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any, Mapping
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_core.core.institutional import (
InstitutionalContextError,
MandateDefinition,
MandateResolution,
MandateResolutionRequest,
TemporalRevision,
resolve_mandate_candidates,
revise_mandate_definition,
)
from govoplan_mandates.backend.db.models import MandateRevision
MAX_RESOLUTION_CANDIDATES = 1_000
class MandateStoreError(ValueError):
pass
def record_mandate(
session: Session,
principal: object,
*,
definition: MandateDefinition,
expected_revision: str | None = None,
) -> MandateDefinition:
tenant_id = _principal_tenant(principal)
_validate_definition_owner(definition, tenant_id=tenant_id)
normalized_payload = definition.to_dict(include_inspection=True)
replay = (
session.query(MandateRevision)
.filter(
MandateRevision.tenant_id == tenant_id,
MandateRevision.mandate_id == definition.reference.object_id,
MandateRevision.revision == definition.temporal.revision,
)
.one_or_none()
)
if replay is not None:
if replay.payload != normalized_payload:
raise MandateStoreError(
"A different Mandate payload already uses this revision."
)
return _definition_from_row(replay)
current_row = _current_row(
session,
tenant_id=tenant_id,
mandate_id=definition.reference.object_id,
lock=True,
)
if current_row is None:
if expected_revision is not None:
raise MandateStoreError(
"Mandate revision conflict: no current revision exists."
)
_validate_new_temporal(definition.temporal)
else:
if expected_revision is None:
raise MandateStoreError(
"Mandate revision conflict: expected_revision is required."
)
current = _definition_from_row(current_row)
try:
lifecycle = revise_mandate_definition(
current,
expected_revision=expected_revision,
temporal=definition.temporal,
status=definition.status,
replacement_ref=definition.replacement_ref,
suspension_reason=definition.suspension_reason,
conflict_refs=definition.conflict_refs,
)
except InstitutionalContextError as exc:
raise MandateStoreError(str(exc)) from exc
if lifecycle.reference != definition.reference:
raise MandateStoreError(
"Mandate reference identity and version must follow its lifecycle revision."
)
current_row.superseded_at = definition.temporal.recorded_at
row = MandateRevision(
tenant_id=tenant_id,
mandate_id=definition.reference.object_id,
revision=definition.temporal.revision,
previous_revision_id=current_row.id if current_row is not None else None,
status=definition.status,
valid_from=definition.temporal.valid_from,
valid_to=definition.temporal.valid_to,
recorded_at=_required_recorded_at(definition.temporal),
payload=normalized_payload,
created_by=_principal_actor(principal),
)
session.add(row)
session.flush()
return _definition_from_row(row)
def get_mandate(
session: Session,
principal: object,
*,
mandate_id: str,
revision: str | None = None,
) -> MandateDefinition | None:
tenant_id = _principal_tenant(principal)
query = session.query(MandateRevision).filter(
MandateRevision.tenant_id == tenant_id,
MandateRevision.mandate_id == mandate_id,
)
if revision is not None:
query = query.filter(MandateRevision.revision == revision)
else:
query = query.filter(MandateRevision.superseded_at.is_(None))
row = query.order_by(MandateRevision.recorded_at.desc()).first()
return _definition_from_row(row) if row is not None else None
def list_mandates(
session: Session,
principal: object,
*,
status: str | None = None,
limit: int = 100,
) -> tuple[MandateDefinition, ...]:
tenant_id = _principal_tenant(principal)
if not 1 <= limit <= 200:
raise MandateStoreError("Mandate list limit must be between 1 and 200.")
query = session.query(MandateRevision).filter(
MandateRevision.tenant_id == tenant_id,
MandateRevision.superseded_at.is_(None),
)
if status:
query = query.filter(MandateRevision.status == status)
rows = query.order_by(
MandateRevision.mandate_id.asc(),
MandateRevision.recorded_at.desc(),
).limit(limit).all()
return tuple(_definition_from_row(row) for row in rows)
class SqlMandateResolver:
def resolve_mandate(
self,
session: object,
principal: object,
*,
request: MandateResolutionRequest,
) -> MandateResolution:
tenant_id = _principal_tenant(principal)
if request.tenant_id != tenant_id:
raise InstitutionalContextError(
"Mandate resolution cannot cross tenants."
)
typed_session = _session(session)
rows = (
typed_session.query(MandateRevision)
.filter(
MandateRevision.tenant_id == tenant_id,
or_(
MandateRevision.valid_from.is_(None),
MandateRevision.valid_from <= request.effective_at,
),
or_(
MandateRevision.valid_to.is_(None),
MandateRevision.valid_to > request.effective_at,
),
)
.order_by(
MandateRevision.mandate_id.asc(),
MandateRevision.recorded_at.desc(),
)
.limit(MAX_RESOLUTION_CANDIDATES + 1)
.all()
)
if len(rows) > MAX_RESOLUTION_CANDIDATES:
raise InstitutionalContextError(
"Mandate resolution candidate bound was exceeded."
)
latest: dict[str, MandateDefinition] = {}
for row in rows:
latest.setdefault(row.mandate_id, _definition_from_row(row))
return resolve_mandate_candidates(request, tuple(latest.values()))
def definition_from_mapping(value: Mapping[str, object]) -> MandateDefinition:
try:
return MandateDefinition.from_mapping(value)
except InstitutionalContextError as exc:
raise MandateStoreError(str(exc)) from exc
def resolution_request_from_mapping(
value: Mapping[str, object],
) -> MandateResolutionRequest:
try:
return MandateResolutionRequest.from_mapping(value)
except InstitutionalContextError as exc:
raise MandateStoreError(str(exc)) from exc
def _current_row(
session: Session,
*,
tenant_id: str,
mandate_id: str,
lock: bool,
) -> MandateRevision | None:
query = session.query(MandateRevision).filter(
MandateRevision.tenant_id == tenant_id,
MandateRevision.mandate_id == mandate_id,
MandateRevision.superseded_at.is_(None),
)
if lock:
query = query.with_for_update()
return query.one_or_none()
def _definition_from_row(row: MandateRevision) -> MandateDefinition:
payload: dict[str, Any] = dict(row.payload)
temporal = dict(payload.get("temporal") or {})
temporal["superseded_at"] = _datetime_text(row.superseded_at)
payload["temporal"] = temporal
return MandateDefinition.from_mapping(payload)
def _validate_definition_owner(
definition: MandateDefinition,
*,
tenant_id: str,
) -> None:
if definition.reference.owner_module != "mandates":
raise MandateStoreError("Mandate definitions must be owned by Mandates.")
if definition.reference.tenant_id != tenant_id:
raise MandateStoreError("Mandate definitions cannot cross tenants.")
if definition.reference.version != definition.temporal.revision:
raise MandateStoreError(
"Mandate reference version must match its temporal revision."
)
if definition.temporal.superseded_at is not None:
raise MandateStoreError("Clients cannot set Mandate superseded_at.")
def _validate_new_temporal(temporal: TemporalRevision) -> None:
_required_recorded_at(temporal)
if not str(temporal.change_reason or "").strip():
raise MandateStoreError(
"A Mandate revision requires recorded_at and change_reason."
)
def _required_recorded_at(temporal: TemporalRevision) -> datetime:
if temporal.recorded_at is None:
raise MandateStoreError("A Mandate revision requires recorded_at.")
return temporal.recorded_at
def _principal_tenant(principal: object) -> str:
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
if not tenant_id:
raise InstitutionalContextError(
"Mandate operations require a tenant-bound principal."
)
return tenant_id
def _principal_actor(principal: object) -> str | None:
for name in ("account_id", "identity_id", "membership_id"):
value = str(getattr(principal, name, "") or "").strip()
if value:
return value
return None
def _session(value: object) -> Session:
if not hasattr(value, "query"):
raise InstitutionalContextError("Mandate resolver requires a database session.")
return value # type: ignore[return-value]
def _datetime_text(value: datetime | None) -> str | None:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=UTC)
return value.isoformat()
__all__ = [
"MAX_RESOLUTION_CANDIDATES",
"MandateStoreError",
"SqlMandateResolver",
"definition_from_mapping",
"get_mandate",
"list_mandates",
"record_mandate",
"resolution_request_from_mapping",
]
+1
View File
@@ -0,0 +1 @@