From 8880818045c38c16695fbfc663392ba3e78273eb Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 1 Aug 2026 17:48:37 +0200 Subject: [PATCH] feat: implement institutional mandate resolver --- .gitea/ISSUE_TEMPLATE/bug_report.md | 35 ++ .gitea/ISSUE_TEMPLATE/config.yaml | 1 + .gitea/ISSUE_TEMPLATE/docs_workflow.md | 27 ++ .gitea/ISSUE_TEMPLATE/feature_request.md | 32 ++ .gitea/ISSUE_TEMPLATE/task.md | 28 ++ .gitea/ISSUE_TEMPLATE/tech_debt.md | 25 ++ .gitea/PULL_REQUEST_TEMPLATE.md | 15 + .gitignore | 353 ++++++++++++++++++ README.md | 20 + docs/MANDATES_DOMAIN.md | 34 ++ pyproject.toml | 21 ++ src/govoplan_mandates/__init__.py | 3 + src/govoplan_mandates/backend/__init__.py | 1 + src/govoplan_mandates/backend/db/__init__.py | 1 + src/govoplan_mandates/backend/db/models.py | 59 +++ src/govoplan_mandates/backend/manifest.py | 166 ++++++++ .../backend/migrations/__init__.py | 1 + .../backend/migrations/versions/__init__.py | 1 + .../a8b1c2d3e4f5_v0114_mandates_baseline.py | 51 +++ src/govoplan_mandates/backend/router.py | 129 +++++++ src/govoplan_mandates/backend/schemas.py | 29 ++ src/govoplan_mandates/backend/service.py | 307 +++++++++++++++ src/govoplan_mandates/py.typed | 1 + tests/test_mandates.py | 129 +++++++ 24 files changed, 1469 insertions(+) create mode 100644 .gitea/ISSUE_TEMPLATE/bug_report.md create mode 100644 .gitea/ISSUE_TEMPLATE/config.yaml create mode 100644 .gitea/ISSUE_TEMPLATE/docs_workflow.md create mode 100644 .gitea/ISSUE_TEMPLATE/feature_request.md create mode 100644 .gitea/ISSUE_TEMPLATE/task.md create mode 100644 .gitea/ISSUE_TEMPLATE/tech_debt.md create mode 100644 .gitea/PULL_REQUEST_TEMPLATE.md create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/MANDATES_DOMAIN.md create mode 100644 pyproject.toml create mode 100644 src/govoplan_mandates/__init__.py create mode 100644 src/govoplan_mandates/backend/__init__.py create mode 100644 src/govoplan_mandates/backend/db/__init__.py create mode 100644 src/govoplan_mandates/backend/db/models.py create mode 100644 src/govoplan_mandates/backend/manifest.py create mode 100644 src/govoplan_mandates/backend/migrations/__init__.py create mode 100644 src/govoplan_mandates/backend/migrations/versions/__init__.py create mode 100644 src/govoplan_mandates/backend/migrations/versions/a8b1c2d3e4f5_v0114_mandates_baseline.py create mode 100644 src/govoplan_mandates/backend/router.py create mode 100644 src/govoplan_mandates/backend/schemas.py create mode 100644 src/govoplan_mandates/backend/service.py create mode 100644 src/govoplan_mandates/py.typed create mode 100644 tests/test_mandates.py diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..cf14bd1 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug_report.md @@ -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: diff --git a/.gitea/ISSUE_TEMPLATE/config.yaml b/.gitea/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.gitea/ISSUE_TEMPLATE/docs_workflow.md b/.gitea/ISSUE_TEMPLATE/docs_workflow.md new file mode 100644 index 0000000..a3b0cca --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/docs_workflow.md @@ -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? diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.gitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..b286a18 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature_request.md @@ -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: diff --git a/.gitea/ISSUE_TEMPLATE/task.md b/.gitea/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000..30ea442 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/task.md @@ -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: diff --git a/.gitea/ISSUE_TEMPLATE/tech_debt.md b/.gitea/ISSUE_TEMPLATE/tech_debt.md new file mode 100644 index 0000000..09eec4f --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/tech_debt.md @@ -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: diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1984736 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +## Issue + +Closes # + +## Summary + +- + +## Verification + +- + +## Notes + +Follow-up issues: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..252e52a --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..28d7203 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# GovOPlaN Mandates + + +**Repository type:** module (domain). + + +`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). diff --git a/docs/MANDATES_DOMAIN.md b/docs/MANDATES_DOMAIN.md new file mode 100644 index 0000000..e4237bc --- /dev/null +++ b/docs/MANDATES_DOMAIN.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0871f93 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/src/govoplan_mandates/__init__.py b/src/govoplan_mandates/__init__.py new file mode 100644 index 0000000..b1e400b --- /dev/null +++ b/src/govoplan_mandates/__init__.py @@ -0,0 +1,3 @@ +"""GovOPlaN institutional mandates module.""" + +__version__ = "0.1.14" diff --git a/src/govoplan_mandates/backend/__init__.py b/src/govoplan_mandates/backend/__init__.py new file mode 100644 index 0000000..688a202 --- /dev/null +++ b/src/govoplan_mandates/backend/__init__.py @@ -0,0 +1 @@ +"""Backend package for GovOPlaN Mandates.""" diff --git a/src/govoplan_mandates/backend/db/__init__.py b/src/govoplan_mandates/backend/db/__init__.py new file mode 100644 index 0000000..ce5c594 --- /dev/null +++ b/src/govoplan_mandates/backend/db/__init__.py @@ -0,0 +1 @@ +"""Database models owned by GovOPlaN Mandates.""" diff --git a/src/govoplan_mandates/backend/db/models.py b/src/govoplan_mandates/backend/db/models.py new file mode 100644 index 0000000..9bf4705 --- /dev/null +++ b/src/govoplan_mandates/backend/db/models.py @@ -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"] diff --git a/src/govoplan_mandates/backend/manifest.py b/src/govoplan_mandates/backend/manifest.py new file mode 100644 index 0000000..02a6214 --- /dev/null +++ b/src/govoplan_mandates/backend/manifest.py @@ -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", +] diff --git a/src/govoplan_mandates/backend/migrations/__init__.py b/src/govoplan_mandates/backend/migrations/__init__.py new file mode 100644 index 0000000..0094cf6 --- /dev/null +++ b/src/govoplan_mandates/backend/migrations/__init__.py @@ -0,0 +1 @@ +"""Mandates database migrations.""" diff --git a/src/govoplan_mandates/backend/migrations/versions/__init__.py b/src/govoplan_mandates/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..f0a5736 --- /dev/null +++ b/src/govoplan_mandates/backend/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Mandates migration revisions.""" diff --git a/src/govoplan_mandates/backend/migrations/versions/a8b1c2d3e4f5_v0114_mandates_baseline.py b/src/govoplan_mandates/backend/migrations/versions/a8b1c2d3e4f5_v0114_mandates_baseline.py new file mode 100644 index 0000000..b77d97a --- /dev/null +++ b/src/govoplan_mandates/backend/migrations/versions/a8b1c2d3e4f5_v0114_mandates_baseline.py @@ -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") diff --git a/src/govoplan_mandates/backend/router.py b/src/govoplan_mandates/backend/router.py new file mode 100644 index 0000000..c6e73a4 --- /dev/null +++ b/src/govoplan_mandates/backend/router.py @@ -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"] diff --git a/src/govoplan_mandates/backend/schemas.py b/src/govoplan_mandates/backend/schemas.py new file mode 100644 index 0000000..ec9fbcc --- /dev/null +++ b/src/govoplan_mandates/backend/schemas.py @@ -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", +] diff --git a/src/govoplan_mandates/backend/service.py b/src/govoplan_mandates/backend/service.py new file mode 100644 index 0000000..78e94c2 --- /dev/null +++ b/src/govoplan_mandates/backend/service.py @@ -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", +] diff --git a/src/govoplan_mandates/py.typed b/src/govoplan_mandates/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_mandates/py.typed @@ -0,0 +1 @@ + diff --git a/tests/test_mandates.py b/tests/test_mandates.py new file mode 100644 index 0000000..6827cb9 --- /dev/null +++ b/tests/test_mandates.py @@ -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()