feat: implement formal decision registry

This commit is contained in:
2026-08-01 17:48:29 +02:00
commit ba8e42fd03
24 changed files with 1236 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/
+17
View File
@@ -0,0 +1,17 @@
# GovOPlaN Decisions
<!-- govoplan-repository-type:start -->
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
`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).
+26
View File
@@ -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.
+21
View File
@@ -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"
+3
View File
@@ -0,0 +1,3 @@
"""GovOPlaN Decisions module."""
__version__ = "0.1.14"
@@ -0,0 +1 @@
"""Decisions backend."""
@@ -0,0 +1,3 @@
from govoplan_decisions.backend.db.models import FormalDecisionRevision
__all__ = ["FormalDecisionRevision"]
@@ -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"]
@@ -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
@@ -0,0 +1 @@
"""Decisions migrations."""
@@ -0,0 +1 @@
"""Decision migration revisions."""
@@ -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")
+91
View File
@@ -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"]
+19
View File
@@ -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"]
+246
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
+101
View File
@@ -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()