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
+35
View File
@@ -0,0 +1,35 @@
---
name: "Bug"
about: "Report a reproducible defect, regression, or incorrect behavior"
title: "[Bug] "
labels:
- type/bug
- status/triage
- module/core
---
## Scope
- Repository:
- Area/module:
- Affected version or commit:
## Behavior
Expected:
Actual:
## Reproduction
1.
2.
3.
## Evidence
Logs, screenshots, traces, or failing test output:
## Verification Target
Command or workflow that should pass when fixed:
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
+27
View File
@@ -0,0 +1,27 @@
---
name: "Docs / workflow"
about: "Request documentation, process, or developer workflow changes"
title: "[Docs] "
labels:
- type/docs
- status/triage
- module/core
- area/docs
---
## Scope
- Repository:
- Document or workflow:
## Current State
What is missing, unclear, duplicated, or stale?
## Desired State
What should the docs or workflow make clear?
## Verification Target
How should this be checked?
+32
View File
@@ -0,0 +1,32 @@
---
name: "Feature"
about: "Propose new user-visible behavior or platform capability"
title: "[Feature] "
labels:
- type/feature
- status/triage
- module/core
---
## Problem
What user, operator, or developer problem should this solve?
## Proposed Capability
What should exist when this is done?
## Ownership
- Owning repository:
- Related module repositories:
- Extension point or integration boundary:
## Acceptance Criteria
- [ ]
- [ ]
## Verification Target
Command, scenario, or UI flow that should prove completion:
+28
View File
@@ -0,0 +1,28 @@
---
name: "Task"
about: "Track implementation, maintenance, or migration work"
title: "[Task] "
labels:
- type/task
- status/triage
- module/core
---
## Objective
What needs to be completed?
## Scope
- Owning repository:
- In-scope:
- Out-of-scope:
## Checklist
- [ ]
- [ ]
## Verification Target
Command or manual check:
+25
View File
@@ -0,0 +1,25 @@
---
name: "Tech debt"
about: "Track cleanup, refactoring, risk reduction, or deferred engineering work"
title: "[Debt] "
labels:
- type/debt
- status/triage
- module/core
---
## Current Cost
What does this make harder, riskier, slower, or more fragile?
## Desired Shape
What should the code, tests, or architecture look like afterwards?
## Constraints
What behavior, compatibility, or module boundary must be preserved?
## Verification Target
Focused checks that should pass:
+15
View File
@@ -0,0 +1,15 @@
## Issue
Closes #
## Summary
-
## Verification
-
## Notes
Follow-up issues:
+353
View File
@@ -0,0 +1,353 @@
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Local WebUI test/build scratch directories
.component-test-build/
.file-drop-test-build/
.module-test-build/
.policy-test-build/
.template-preview-test-build/
.import-test-build/
webui/.component-test-build/
webui/.file-drop-test-build/
webui/.module-test-build/
webui/.policy-test-build/
webui/.template-preview-test-build/
webui/.import-test-build/
# Security audit reports
audit-reports/
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# ---> VisualStudioCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
*.db
# GovOPlaN local runtime state
runtime/
# GovOPlaN WebUI test output
webui/.module-test-build/
webui/.component-test-build/
+20
View File
@@ -0,0 +1,20 @@
# GovOPlaN Mandates
<!-- govoplan-repository-type:start -->
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
`govoplan-mandates` owns effective-dated institutional competence: tasks,
jurisdiction, responsibility, decision or signature authority, legal basis,
evidence, conflicts, suspension, replacement, and retirement.
Organizations owns structures and functions, IDM owns incumbency, Access owns
application permissions, and Policy owns constraints. Mandates answers why an
institution, unit, or function was competent to perform a governed action at a
specific time.
The module stores immutable mandate revisions and exposes the provider-neutral
`mandates.resolver` capability. Consumers retain exact returned references and
evidence instead of reading Mandates tables.
See [docs/MANDATES_DOMAIN.md](docs/MANDATES_DOMAIN.md).
+34
View File
@@ -0,0 +1,34 @@
# Mandates Domain
## Ownership
Mandates owns stable mandate identities and immutable revisions. A revision
states effective time, task and authority types, constrained organization
units/functions, jurisdiction, subject scope, authority ceiling, legal bases,
evidence, lifecycle state, and explicit conflicts.
It does not own organization structures, function incumbency, accounts,
application permissions, policy rules, cases, or formal decisions.
## Resolution
Resolution is tenant-bound and deterministic. It evaluates the requested time,
task, authority, unit, function, jurisdiction, and subject against the latest
effective revision of each mandate. Conflicting effective mandates fail closed
and return their conflict/evidence references.
Consumers must freeze the exact mandate reference and evidence used for a
consequential action. A later mandate correction does not rewrite historical
decisions or effects.
## Revision And Recovery
Creation and revision use optimistic concurrency. Every new revision has a new
revision identifier, `recorded_at`, and change reason. Lifecycle transitions
follow the shared Core contract. Rows are append-only apart from marking the
previous row superseded; destructive retirement requires the platform database
snapshot and module-retirement preflight.
Database restore is the recovery unit. Replaying a request with the same
tenant, mandate identity, and revision is idempotent; a different payload under
an existing revision is rejected.
+21
View File
@@ -0,0 +1,21 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-mandates"
version = "0.1.14"
description = "Effective institutional mandate, jurisdiction, and authority lifecycle for GovOPlaN."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = ["govoplan-core>=0.1.14"]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
govoplan_mandates = ["py.typed"]
[project.entry-points."govoplan.modules"]
"mandates" = "govoplan_mandates.backend.manifest:get_manifest"
+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 @@
+129
View File
@@ -0,0 +1,129 @@
from __future__ import annotations
from dataclasses import dataclass, replace
from datetime import UTC, datetime, timedelta
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.institutional import (
InstitutionalReference,
MandateDefinition,
MandateResolutionRequest,
TemporalRevision,
)
from govoplan_mandates.backend.db.models import MandateRevision
from govoplan_mandates.backend.service import (
MandateStoreError,
SqlMandateResolver,
get_mandate,
record_mandate,
)
NOW = datetime(2026, 8, 1, 9, 0, tzinfo=UTC)
@dataclass
class Principal:
tenant_id: str = "tenant-1"
account_id: str = "account-1"
def definition(*, revision: str = "1", status: str = "active") -> MandateDefinition:
temporal = TemporalRevision(
revision=revision,
valid_from=NOW,
recorded_at=NOW + timedelta(minutes=int(revision) - 1),
change_reason="Initial authority." if revision == "1" else "Authority changed.",
)
return MandateDefinition(
reference=InstitutionalReference(
kind="mandate",
owner_module="mandates",
object_id="committee-permit",
tenant_id="tenant-1",
version=revision,
valid_at=NOW,
),
temporal=temporal,
task_types=("committee.formal_decision",),
authority_types=("permit",),
status=status, # type: ignore[arg-type]
suspension_reason="Review pending." if status == "suspended" else None,
)
class MandateTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
MandateRevision.__table__.create(self.engine)
self.session = Session(self.engine)
self.principal = Principal()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_record_resolve_replay_and_occ(self) -> None:
first = definition()
recorded = record_mandate(self.session, self.principal, definition=first)
self.assertEqual("1", recorded.temporal.revision)
replay = record_mandate(self.session, self.principal, definition=first)
self.assertEqual("1", replay.temporal.revision)
request = MandateResolutionRequest(
tenant_id="tenant-1",
effective_at=NOW + timedelta(hours=1),
task_type="committee.formal_decision",
authority_type="permit",
)
resolved = SqlMandateResolver().resolve_mandate(
self.session,
self.principal,
request=request,
)
self.assertTrue(resolved.competent)
self.assertEqual("1", resolved.mandates[0].temporal.revision)
suspended = definition(revision="2", status="suspended")
record_mandate(
self.session,
self.principal,
definition=suspended,
expected_revision="1",
)
self.assertFalse(
SqlMandateResolver().resolve_mandate(
self.session,
self.principal,
request=request,
).competent
)
with self.assertRaisesRegex(MandateStoreError, "stale"):
stale = replace(
suspended,
reference=replace(suspended.reference, version="3"),
temporal=replace(suspended.temporal, revision="3"),
)
record_mandate(
self.session,
self.principal,
definition=stale,
expected_revision="1",
)
def test_tenant_isolation(self) -> None:
record_mandate(self.session, self.principal, definition=definition())
self.assertIsNone(
get_mandate(
self.session,
Principal(tenant_id="tenant-2"),
mandate_id="committee-permit",
)
)
if __name__ == "__main__":
unittest.main()