commit ba8e42fd03b43bbeacdc88bebdcd1a0b6dde455d Author: Albrecht Degering Date: Sat Aug 1 17:48:29 2026 +0200 feat: implement formal decision registry 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..3ac1a8e --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# GovOPlaN Decisions + + +**Repository type:** module (domain). + + +`govoplan-decisions` owns reconstructable formal institutional outcomes: +authority context, exact mandate and rule versions, facts and evidence, +reasoning, operative result, conditions, requested and observed effects, +delivery/publication, remedies, reviews, corrections, revocations, and +supersession. + +Approvals owns review gates, Committee owns deliberation and voting, Workflow +owns coordination, and domain modules own their effects. They record formal +outcomes through `decisions.registry` without reading Decisions tables. + +See [docs/DECISIONS_DOMAIN.md](docs/DECISIONS_DOMAIN.md). diff --git a/docs/DECISIONS_DOMAIN.md b/docs/DECISIONS_DOMAIN.md new file mode 100644 index 0000000..6f86b4f --- /dev/null +++ b/docs/DECISIONS_DOMAIN.md @@ -0,0 +1,26 @@ +# Formal Decisions Domain + +## Ownership And Reconstruction + +Decisions stores immutable formal-outcome revisions. Each outcome retains the +responsible human or service actor, represented function/party, effective +Mandate and jurisdiction, approvals, fact evidence, legal bases, rule versions, +reasoning, operative result, requested and observed effects, delivery, +publication, remedy, and review references. + +The ordinary read projection withholds protected reasoning and operative +content. A separate sensitive-read permission is required to disclose it. + +## Lifecycle + +Writes use the shared Decision transition matrix and optimistic concurrency. +Correction and revocation create linked revisions; they never edit the previous +outcome in place. Replaying an identical tenant/decision/revision is idempotent, +while a different payload under that revision fails. + +## Recovery + +Database restore is the persistence recovery unit. Requested effects remain +separate from observed effects and keep audit/evidence references, allowing an +operator to reconcile an outcome whose external effect was uncertain. Domain +effect providers retain their own compensation and recovery behavior. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1c57d92 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "govoplan-decisions" +version = "0.1.14" +description = "Formal institutional decision lifecycle and reconstruction 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_decisions = ["py.typed"] + +[project.entry-points."govoplan.modules"] +"decisions" = "govoplan_decisions.backend.manifest:get_manifest" diff --git a/src/govoplan_decisions/__init__.py b/src/govoplan_decisions/__init__.py new file mode 100644 index 0000000..cf92884 --- /dev/null +++ b/src/govoplan_decisions/__init__.py @@ -0,0 +1,3 @@ +"""GovOPlaN Decisions module.""" + +__version__ = "0.1.14" diff --git a/src/govoplan_decisions/backend/__init__.py b/src/govoplan_decisions/backend/__init__.py new file mode 100644 index 0000000..3928dda --- /dev/null +++ b/src/govoplan_decisions/backend/__init__.py @@ -0,0 +1 @@ +"""Decisions backend.""" diff --git a/src/govoplan_decisions/backend/db/__init__.py b/src/govoplan_decisions/backend/db/__init__.py new file mode 100644 index 0000000..508ae18 --- /dev/null +++ b/src/govoplan_decisions/backend/db/__init__.py @@ -0,0 +1,3 @@ +from govoplan_decisions.backend.db.models import FormalDecisionRevision + +__all__ = ["FormalDecisionRevision"] diff --git a/src/govoplan_decisions/backend/db/models.py b/src/govoplan_decisions/backend/db/models.py new file mode 100644 index 0000000..83d8170 --- /dev/null +++ b/src/govoplan_decisions/backend/db/models.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any +import uuid + +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 FormalDecisionRevision(Base, TimestampMixin): + __tablename__ = "formal_decision_revisions" + __table_args__ = ( + UniqueConstraint("tenant_id", "decision_id", "revision", name="uq_formal_decision_revision"), + Index("ix_formal_decision_current", "tenant_id", "decision_id", "superseded_at"), + Index("ix_formal_decision_catalogue", "tenant_id", "state", "decision_type", "recorded_at"), + ) + + 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) + decision_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("formal_decision_revisions.id", ondelete="RESTRICT"), + nullable=True, + index=True, + ) + decision_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + state: 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__ = ["FormalDecisionRevision"] diff --git a/src/govoplan_decisions/backend/manifest.py b/src/govoplan_decisions/backend/manifest.py new file mode 100644 index 0000000..243fcab --- /dev/null +++ b/src/govoplan_decisions/backend/manifest.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from pathlib import Path + +from govoplan_core.core.institutional import CAPABILITY_DECISION_REGISTRY +from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard +from govoplan_core.core.modules import CapabilityDocumentation, DocumentationLink, DocumentationTopic, MigrationSpec, ModuleContext, ModuleInterfaceProvider, ModuleManifest, PermissionDefinition, RoleTemplate +from govoplan_core.core.provider_governance import declared_module_architecture +from govoplan_core.db.base import Base +from govoplan_decisions.backend.db import models as decision_models +from govoplan_decisions.backend.service import SqlDecisionRegistry + + +MODULE_ID = "decisions" +MODULE_NAME = "Decisions" +MODULE_VERSION = "0.1.14" +READ_SCOPE = "decisions:decision:read" +SENSITIVE_READ_SCOPE = "decisions:decision:read_sensitive" +WRITE_SCOPE = "decisions:decision:write" +ADMIN_SCOPE = "decisions:decision:admin" + + +def _permission(scope: str, label: str, description: str) -> PermissionDefinition: + module_id, resource, action = scope.split(":", 2) + return PermissionDefinition(scope=scope, label=label, description=description, category="Decisions", level="tenant", module_id=module_id, resource=resource, action=action) + + +def _router(_context: ModuleContext): + from govoplan_decisions.backend.router import router + + return router + + +def _registry(_context: ModuleContext) -> SqlDecisionRegistry: + return SqlDecisionRegistry() + + +manifest = ModuleManifest( + id=MODULE_ID, + name=MODULE_NAME, + version=MODULE_VERSION, + optional_dependencies=("mandates", "approvals", "committee", "cases", "audit", "policy", "records", "files", "postbox", "mail"), + provides_interfaces=(ModuleInterfaceProvider(name="decisions.formal_outcome", version="0.1.0"), ModuleInterfaceProvider(name="decisions.reconstruction", version="0.1.0")), + permissions=( + _permission(READ_SCOPE, "View Decision metadata", "View formal Decision metadata and evidence references."), + _permission(SENSITIVE_READ_SCOPE, "View protected Decisions", "View protected reasoning, operative results, and conditions."), + _permission(WRITE_SCOPE, "Record Decisions", "Record governed formal outcomes and lifecycle revisions."), + _permission(ADMIN_SCOPE, "Administer Decisions", "Administer Decision access, lifecycle, and recovery."), + ), + role_templates=( + RoleTemplate(slug="decision_officer", name="Decision officer", description="Record and inspect formal Decisions.", permissions=(READ_SCOPE, SENSITIVE_READ_SCOPE, WRITE_SCOPE)), + RoleTemplate(slug="decision_auditor", name="Decision auditor", description="Reconstruct protected formal outcomes.", permissions=(READ_SCOPE, SENSITIVE_READ_SCOPE)), + ), + route_factory=_router, + capability_factories={CAPABILITY_DECISION_REGISTRY: _registry}, + capability_documentation={CAPABILITY_DECISION_REGISTRY: CapabilityDocumentation(label="Formal Decision registry", summary="Records and resolves immutable, reconstructable formal outcomes.", contract_version="0.1.0")}, + migration_spec=MigrationSpec( + module_id=MODULE_ID, + metadata=Base.metadata, + script_location=str(Path(__file__).with_name("migrations") / "versions"), + retirement_supported=True, + retirement_provider=drop_table_retirement_provider(decision_models.FormalDecisionRevision, label="Decisions"), + retirement_notes="Destructive retirement requires a database snapshot and removes formal Decision history.", + ), + uninstall_guard_providers=(persistent_table_uninstall_guard(decision_models.FormalDecisionRevision, label="Decisions"),), + documentation=( + DocumentationTopic( + id="decisions.formal-outcome", + title="Formal institutional Decisions", + summary="Reconstruct authority, evidence, reasoning, outcome, effects, and review history.", + body="Decisions preserves immutable formal outcomes. Corrections and revocations create linked revisions; requested and observed effects remain distinct for reconciliation.", + layer="configured", + documentation_types=("admin", "user"), + audience=("user", "operator", "module_admin", "auditor"), + links=(DocumentationLink(label="Decisions domain and recovery", href="govoplan-decisions/docs/DECISIONS_DOMAIN.md", kind="repository"),), + ), + ), + architecture=declared_module_architecture( + layer="governance_accountability", + kind="domain", + maturity="vertical_slice", + documentation_ref="docs/DECISIONS_DOMAIN.md", + test_ref="tests/test_decisions.py", + known_limits=("No dedicated Decision WebUI is included; consuming procedure modules present outcomes in context.",), + supported_authority_modes=("native_authoritative",), + owned_concepts=("formal decision", "decision correction", "decision effect observation"), + non_owned_concepts=("approval gate", "committee deliberation", "effect execution", "record binary"), + reference_packages=("product.service-to-decision",), + migration_docs=("docs/DECISIONS_DOMAIN.md",), + recovery_docs=("docs/DECISIONS_DOMAIN.md",), + security_docs=("docs/DECISIONS_DOMAIN.md",), + operations_docs=("docs/DECISIONS_DOMAIN.md",), + ), +) + + +def get_manifest() -> ModuleManifest: + return manifest diff --git a/src/govoplan_decisions/backend/migrations/__init__.py b/src/govoplan_decisions/backend/migrations/__init__.py new file mode 100644 index 0000000..a5649f6 --- /dev/null +++ b/src/govoplan_decisions/backend/migrations/__init__.py @@ -0,0 +1 @@ +"""Decisions migrations.""" diff --git a/src/govoplan_decisions/backend/migrations/versions/__init__.py b/src/govoplan_decisions/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..dc20446 --- /dev/null +++ b/src/govoplan_decisions/backend/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Decision migration revisions.""" diff --git a/src/govoplan_decisions/backend/migrations/versions/d1e4f5a6b7c8_v0114_decisions_baseline.py b/src/govoplan_decisions/backend/migrations/versions/d1e4f5a6b7c8_v0114_decisions_baseline.py new file mode 100644 index 0000000..3fae90d --- /dev/null +++ b/src/govoplan_decisions/backend/migrations/versions/d1e4f5a6b7c8_v0114_decisions_baseline.py @@ -0,0 +1,47 @@ +"""v0.1.14 Decisions baseline. + +Revision ID: d1e4f5a6b7c8 +Revises: None +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "d1e4f5a6b7c8" +down_revision = None +branch_labels = None +depends_on = "4f2a9c8e7b6d" + + +def upgrade() -> None: + op.create_table( + "formal_decision_revisions", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("decision_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("decision_type", sa.String(length=120), nullable=False), + sa.Column("state", 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"], ["formal_decision_revisions.id"], name=op.f("fk_formal_decision_revisions_previous_revision_id_formal_decision_revisions"), ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_formal_decision_revisions")), + sa.UniqueConstraint("tenant_id", "decision_id", "revision", name="uq_formal_decision_revision"), + ) + for column in ("tenant_id", "decision_id", "previous_revision_id", "decision_type", "state", "recorded_at", "superseded_at", "created_by"): + op.create_index(op.f(f"ix_formal_decision_revisions_{column}"), "formal_decision_revisions", [column], unique=False) + op.create_index("ix_formal_decision_current", "formal_decision_revisions", ["tenant_id", "decision_id", "superseded_at"], unique=False) + op.create_index("ix_formal_decision_catalogue", "formal_decision_revisions", ["tenant_id", "state", "decision_type", "recorded_at"], unique=False) + + +def downgrade() -> None: + op.drop_table("formal_decision_revisions") diff --git a/src/govoplan_decisions/backend/router.py b/src/govoplan_decisions/backend/router.py new file mode 100644 index 0000000..a4d7be0 --- /dev/null +++ b/src/govoplan_decisions/backend/router.py @@ -0,0 +1,91 @@ +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, InstitutionalReference +from govoplan_core.db.session import get_session +from govoplan_decisions.backend.manifest import READ_SCOPE, SENSITIVE_READ_SCOPE, WRITE_SCOPE +from govoplan_decisions.backend.schemas import DecisionListResponse, DecisionWriteRequest +from govoplan_decisions.backend.service import DecisionStoreError, SqlDecisionRegistry, decision_from_mapping, list_decisions + + +router = APIRouter(prefix="/decisions", tags=["decisions"]) + + +def _require(principal: ApiPrincipal, scope: str) -> None: + if not has_scope(principal, scope): + raise HTTPException(status_code=403, detail=f"Missing scope: {scope}") + + +def _protected(principal: ApiPrincipal, requested: bool) -> bool: + if requested: + _require(principal, SENSITIVE_READ_SCOPE) + return requested + + +def _error(exc: Exception) -> HTTPException: + message = str(exc) + return HTTPException(status_code=409 if "conflict" in message.lower() else 400, detail=message) + + +@router.get("", response_model=DecisionListResponse) +def api_list_decisions( + decision_state: str | None = Query(default=None, alias="state"), + decision_type: str | None = None, + include_protected: bool = False, + limit: int = Query(default=100, ge=1, le=200), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> DecisionListResponse: + _require(principal, READ_SCOPE) + disclose = _protected(principal, include_protected) + items = list_decisions(session, principal, state=decision_state, decision_type=decision_type, limit=limit) + return DecisionListResponse(decisions=[item.to_dict(include_protected=disclose) for item in items]) + + +@router.get("/{decision_id}", response_model=dict[str, Any]) +def api_get_decision( + decision_id: str, + revision: str | None = None, + include_protected: bool = False, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, Any]: + _require(principal, READ_SCOPE) + disclose = _protected(principal, include_protected) + item = SqlDecisionRegistry().get_decision( + session, + principal, + reference=InstitutionalReference(kind="decision", owner_module="decisions", object_id=decision_id, tenant_id=principal.tenant_id, version=revision), + ) + if item is None: + raise HTTPException(status_code=404, detail="Formal Decision not found") + return item.to_dict(include_protected=disclose) + + +@router.post("", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED) +def api_record_decision( + payload: DecisionWriteRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, Any]: + _require(principal, WRITE_SCOPE) + try: + item = SqlDecisionRegistry().record_decision( + session, + principal, + decision=decision_from_mapping(payload.decision), + expected_revision=payload.expected_revision, + ) + session.commit() + except (DecisionStoreError, InstitutionalContextError) as exc: + session.rollback() + raise _error(exc) from exc + return item.to_dict(include_protected=True) + + +__all__ = ["router"] diff --git a/src/govoplan_decisions/backend/schemas.py b/src/govoplan_decisions/backend/schemas.py new file mode 100644 index 0000000..3e3e6ec --- /dev/null +++ b/src/govoplan_decisions/backend/schemas.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class DecisionWriteRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + decision: dict[str, Any] + expected_revision: str | None = Field(default=None, max_length=120) + + +class DecisionListResponse(BaseModel): + decisions: list[dict[str, Any]] + + +__all__ = ["DecisionListResponse", "DecisionWriteRequest"] diff --git a/src/govoplan_decisions/backend/service.py b/src/govoplan_decisions/backend/service.py new file mode 100644 index 0000000..8990f9f --- /dev/null +++ b/src/govoplan_decisions/backend/service.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any, Mapping + +from sqlalchemy.orm import Session + +from govoplan_core.core.institutional import ( + FormalDecision, + InstitutionalContextError, + InstitutionalReference, + TemporalRevision, + revise_formal_decision, +) +from govoplan_decisions.backend.db.models import FormalDecisionRevision + + +class DecisionStoreError(ValueError): + pass + + +class SqlDecisionRegistry: + def get_decision( + self, + session: object, + principal: object, + *, + reference: InstitutionalReference, + ) -> FormalDecision | None: + tenant_id = _principal_tenant(principal) + if reference.kind != "decision" or reference.tenant_id != tenant_id: + raise InstitutionalContextError( + "Decision lookup requires a same-tenant Decision reference." + ) + query = _session(session).query(FormalDecisionRevision).filter( + FormalDecisionRevision.tenant_id == tenant_id, + FormalDecisionRevision.decision_id == reference.object_id, + ) + if reference.version is None: + query = query.filter(FormalDecisionRevision.superseded_at.is_(None)) + else: + query = query.filter(FormalDecisionRevision.revision == reference.version) + row = query.order_by(FormalDecisionRevision.recorded_at.desc()).first() + return _decision_from_row(row) if row is not None else None + + def record_decision( + self, + session: object, + principal: object, + *, + decision: FormalDecision, + expected_revision: str | None = None, + ) -> FormalDecision: + typed_session = _session(session) + tenant_id = _principal_tenant(principal) + _validate_decision(decision, tenant_id=tenant_id) + payload = decision.to_dict(include_protected=True) + replay = ( + typed_session.query(FormalDecisionRevision) + .filter( + FormalDecisionRevision.tenant_id == tenant_id, + FormalDecisionRevision.decision_id == decision.reference.object_id, + FormalDecisionRevision.revision == decision.temporal.revision, + ) + .one_or_none() + ) + if replay is not None: + if replay.payload != payload: + raise DecisionStoreError( + "A different Decision payload already uses this revision." + ) + return _decision_from_row(replay) + + current_row = _current_row( + typed_session, + tenant_id=tenant_id, + decision_id=decision.reference.object_id, + lock=True, + ) + _validate_temporal(decision.temporal) + if current_row is None: + if expected_revision is not None: + raise DecisionStoreError( + "Decision revision conflict: no current revision exists." + ) + else: + current = _decision_from_row(current_row) + try: + revised = revise_formal_decision( + current, + expected_revision=expected_revision or "", + temporal=decision.temporal, + state=decision.state, + operative_result=decision.operative_result, + reasoning=decision.reasoning, + conditions=decision.conditions, + requested_effects=decision.requested_effects, + observed_effects=decision.observed_effects, + delivery_refs=decision.delivery_refs, + publication_refs=decision.publication_refs, + remedy_refs=decision.remedy_refs, + review_refs=decision.review_refs, + assurance_level=decision.assurance_level, + automation_preparation_refs=decision.automation_preparation_refs, + ) + except InstitutionalContextError as exc: + raise DecisionStoreError(str(exc)) from exc + if revised != decision: + raise DecisionStoreError( + "Decision authority, subjects, facts, and legal bases cannot be rewritten by a lifecycle revision." + ) + current_row.superseded_at = decision.temporal.recorded_at + + row = FormalDecisionRevision( + tenant_id=tenant_id, + decision_id=decision.reference.object_id, + revision=decision.temporal.revision, + previous_revision_id=current_row.id if current_row is not None else None, + decision_type=decision.decision_type, + state=decision.state, + valid_from=decision.temporal.valid_from, + valid_to=decision.temporal.valid_to, + recorded_at=_recorded_at(decision.temporal), + payload=payload, + created_by=_principal_actor(principal), + ) + typed_session.add(row) + typed_session.flush() + return _decision_from_row(row) + + +def list_decisions( + session: Session, + principal: object, + *, + state: str | None = None, + decision_type: str | None = None, + limit: int = 100, +) -> tuple[FormalDecision, ...]: + tenant_id = _principal_tenant(principal) + if not 1 <= limit <= 200: + raise DecisionStoreError("Decision list limit must be between 1 and 200.") + query = session.query(FormalDecisionRevision).filter( + FormalDecisionRevision.tenant_id == tenant_id, + FormalDecisionRevision.superseded_at.is_(None), + ) + if state: + query = query.filter(FormalDecisionRevision.state == state) + if decision_type: + query = query.filter(FormalDecisionRevision.decision_type == decision_type) + rows = query.order_by(FormalDecisionRevision.recorded_at.desc()).limit(limit).all() + return tuple(_decision_from_row(row) for row in rows) + + +def decision_from_mapping(value: Mapping[str, object]) -> FormalDecision: + try: + return FormalDecision.from_mapping(value) + except InstitutionalContextError as exc: + raise DecisionStoreError(str(exc)) from exc + + +def reference_from_mapping(value: Mapping[str, object]) -> InstitutionalReference: + try: + return InstitutionalReference.from_mapping(value) + except InstitutionalContextError as exc: + raise DecisionStoreError(str(exc)) from exc + + +def _current_row(session: Session, *, tenant_id: str, decision_id: str, lock: bool) -> FormalDecisionRevision | None: + query = session.query(FormalDecisionRevision).filter( + FormalDecisionRevision.tenant_id == tenant_id, + FormalDecisionRevision.decision_id == decision_id, + FormalDecisionRevision.superseded_at.is_(None), + ) + if lock: + query = query.with_for_update() + return query.one_or_none() + + +def _decision_from_row(row: FormalDecisionRevision) -> FormalDecision: + payload: dict[str, Any] = dict(row.payload) + superseded_at = _datetime_text(row.superseded_at) + temporal = dict(payload.get("temporal") or {}) + temporal["superseded_at"] = superseded_at + payload["temporal"] = temporal + authority = dict(payload.get("authority_context") or {}) + authority_temporal = dict(authority.get("temporal") or {}) + authority_temporal["superseded_at"] = superseded_at + authority["temporal"] = authority_temporal + payload["authority_context"] = authority + return FormalDecision.from_mapping(payload) + + +def _validate_decision(decision: FormalDecision, *, tenant_id: str) -> None: + if decision.reference.owner_module != "decisions": + raise DecisionStoreError("Formal Decisions persisted in the registry must be owned by Decisions.") + if decision.reference.tenant_id != tenant_id: + raise DecisionStoreError("Formal Decisions cannot cross tenants.") + if decision.reference.version != decision.temporal.revision: + raise DecisionStoreError("Decision reference version must match its temporal revision.") + if decision.temporal.superseded_at is not None: + raise DecisionStoreError("Clients cannot set Decision superseded_at.") + + +def _validate_temporal(temporal: TemporalRevision) -> None: + _recorded_at(temporal) + if not str(temporal.change_reason or "").strip(): + raise DecisionStoreError("A Decision revision requires recorded_at and change_reason.") + + +def _recorded_at(temporal: TemporalRevision) -> datetime: + if temporal.recorded_at is None: + raise DecisionStoreError("A Decision 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("Decision 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("Decision registry 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__ = ["DecisionStoreError", "SqlDecisionRegistry", "decision_from_mapping", "list_decisions", "reference_from_mapping"] diff --git a/src/govoplan_decisions/py.typed b/src/govoplan_decisions/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_decisions/py.typed @@ -0,0 +1 @@ + diff --git a/tests/test_decisions.py b/tests/test_decisions.py new file mode 100644 index 0000000..7af8c2e --- /dev/null +++ b/tests/test_decisions.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from govoplan_core.core.institutional import ( + ActorRepresentationReference, + EvidenceReference, + FormalDecision, + GovernedContextEnvelope, + InstitutionalReference, + LegalBasisReference, + TemporalRevision, + revise_formal_decision, +) +from govoplan_decisions.backend.db.models import FormalDecisionRevision +from govoplan_decisions.backend.service import DecisionStoreError, SqlDecisionRegistry + + +NOW = datetime(2026, 8, 1, 12, 0, tzinfo=UTC) + + +@dataclass +class Principal: + tenant_id: str = "tenant-1" + account_id: str = "account-1" + + +def reference(kind: str, object_id: str, owner: str, version: str | None = "1") -> InstitutionalReference: + return InstitutionalReference(kind=kind, owner_module=owner, object_id=object_id, tenant_id="tenant-1", version=version, valid_at=NOW) # type: ignore[arg-type] + + +def decision() -> FormalDecision: + temporal = TemporalRevision(revision="1", valid_from=NOW, recorded_at=NOW, change_reason="Decision accepted.") + decision_ref = reference("decision", "decision-1", "decisions") + mandate_ref = reference("mandate", "mandate-1", "mandates", "7") + evidence = EvidenceReference(kind="record", owner_module="committee", evidence_id="minutes-1", tenant_id="tenant-1", version="1", captured_at=NOW) + basis = LegalBasisReference(kind="statute", authority="Council", reference="rules:12", version="2026") + context = GovernedContextEnvelope( + tenant_id="tenant-1", + temporal=temporal, + actor=ActorRepresentationReference(tenant_id="tenant-1", account_id="account-1", identity_id="identity-1", mandate_ref=mandate_ref), + mandate_ref=mandate_ref, + decision_ref=decision_ref, + legal_bases=(basis,), + evidence=(evidence,), + ) + return FormalDecision( + reference=decision_ref, + temporal=temporal, + decision_type="committee.resolution", + subject_refs=(reference("case", "case-1", "cases"),), + state="decided", + authority_context=context, + fact_evidence=(evidence,), + legal_bases=(basis,), + operative_result="Approved.", + reasoning="Requirements are met.", + ) + + +class DecisionTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite+pysqlite:///:memory:") + FormalDecisionRevision.__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_replay_revise_and_exact_history(self) -> None: + registry = SqlDecisionRegistry() + first = registry.record_decision(self.session, self.principal, decision=decision()) + self.assertEqual(first, registry.record_decision(self.session, self.principal, decision=decision())) + temporal = TemporalRevision(revision="2", valid_from=NOW, recorded_at=NOW + timedelta(minutes=1), change_reason="Effect confirmed.") + revised = revise_formal_decision(first, expected_revision="1", temporal=temporal, state="effective") + registry.record_decision(self.session, self.principal, decision=revised, expected_revision="1") + current = registry.get_decision(self.session, self.principal, reference=reference("decision", "decision-1", "decisions", None)) + historical = registry.get_decision(self.session, self.principal, reference=first.reference) + self.assertEqual("2", current.temporal.revision if current else None) + self.assertEqual("1", historical.temporal.revision if historical else None) + + def test_occ_and_tenant_isolation(self) -> None: + registry = SqlDecisionRegistry() + first = registry.record_decision(self.session, self.principal, decision=decision()) + temporal = TemporalRevision(revision="2", valid_from=NOW, recorded_at=NOW + timedelta(minutes=1), change_reason="Effect confirmed.") + revised = revise_formal_decision(first, expected_revision="1", temporal=temporal, state="effective") + with self.assertRaisesRegex(DecisionStoreError, "stale"): + registry.record_decision(self.session, self.principal, decision=revised, expected_revision="0") + with self.assertRaisesRegex(Exception, "same-tenant"): + registry.get_decision(self.session, Principal("tenant-2"), reference=first.reference) + + +if __name__ == "__main__": + unittest.main()