commit 87c705b30ed36deaf1bffbef5966d9aae80b5aea Author: Albrecht Degering Date: Sat Aug 1 17:48:37 2026 +0200 feat: implement procedure party 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..cd09233 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# GovOPlaN Parties + + +**Repository type:** module (domain). + + +`govoplan-parties` owns how a subject participates in a concrete procedure: +roles, preferred and permitted channels, delivery-recipient status, frozen +contact snapshots, evidence, and effective representation powers. + +Identity and Organizations continue to own subject masters. Addresses owns +contact points. Cases and other procedures consume effective Party projections +through `parties.resolver` instead of copying representation logic. + +See [docs/PARTIES_DOMAIN.md](docs/PARTIES_DOMAIN.md). diff --git a/docs/PARTIES_DOMAIN.md b/docs/PARTIES_DOMAIN.md new file mode 100644 index 0000000..aaf4d82 --- /dev/null +++ b/docs/PARTIES_DOMAIN.md @@ -0,0 +1,25 @@ +# Parties And Representation Domain + +## Ownership + +Parties owns procedure-local participation and representation authority. A +Party references, but does not copy, an Identity, Organization, group, or +external subject. It belongs to an exact case, workflow, or decision context. + +Contact delivery uses frozen snapshot references so later address changes do +not rewrite evidence. Preferred channels must be permitted by the procedure. + +## Representation + +Representation powers identify representative and represented Parties, an +external or internal power reference, permitted actions, effective time, and +evidence. Existing powers cannot be removed or overwritten. They remain in the +Party revision and are ended through an explicit, OCC-guarded revocation +revision. Expired or revoked powers are not effective for downstream delivery. + +## Revision And Recovery + +Party writes are append-only, replay-safe, tenant-bound, and protected by +optimistic concurrency. The procedure identity cannot change across revisions. +Database restore is the recovery unit; downstream effects preserve exact Party, +contact-snapshot, and representation evidence references. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..12e3483 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "govoplan-parties" +version = "0.1.14" +description = "Procedure-local party and representation 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_parties = ["py.typed"] + +[project.entry-points."govoplan.modules"] +"parties" = "govoplan_parties.backend.manifest:get_manifest" diff --git a/src/govoplan_parties/__init__.py b/src/govoplan_parties/__init__.py new file mode 100644 index 0000000..a951063 --- /dev/null +++ b/src/govoplan_parties/__init__.py @@ -0,0 +1,3 @@ +"""GovOPlaN Parties module.""" + +__version__ = "0.1.14" diff --git a/src/govoplan_parties/backend/__init__.py b/src/govoplan_parties/backend/__init__.py new file mode 100644 index 0000000..f2102d1 --- /dev/null +++ b/src/govoplan_parties/backend/__init__.py @@ -0,0 +1 @@ +"""Parties backend.""" diff --git a/src/govoplan_parties/backend/db/__init__.py b/src/govoplan_parties/backend/db/__init__.py new file mode 100644 index 0000000..ceb1960 --- /dev/null +++ b/src/govoplan_parties/backend/db/__init__.py @@ -0,0 +1,3 @@ +from govoplan_parties.backend.db.models import ProcedurePartyRevision + +__all__ = ["ProcedurePartyRevision"] diff --git a/src/govoplan_parties/backend/db/models.py b/src/govoplan_parties/backend/db/models.py new file mode 100644 index 0000000..f0ad716 --- /dev/null +++ b/src/govoplan_parties/backend/db/models.py @@ -0,0 +1,46 @@ +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 ProcedurePartyRevision(Base, TimestampMixin): + __tablename__ = "procedure_party_revisions" + __table_args__ = ( + UniqueConstraint("tenant_id", "party_id", "revision", name="uq_procedure_party_revision"), + Index("ix_procedure_party_current", "tenant_id", "party_id", "superseded_at"), + Index("ix_procedure_party_resolution", "tenant_id", "procedure_kind", "procedure_owner_module", "procedure_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) + party_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + procedure_kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True) + procedure_owner_module: Mapped[str] = mapped_column(String(80), nullable=False, index=True) + procedure_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("procedure_party_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__ = ["ProcedurePartyRevision"] diff --git a/src/govoplan_parties/backend/manifest.py b/src/govoplan_parties/backend/manifest.py new file mode 100644 index 0000000..04ac2e0 --- /dev/null +++ b/src/govoplan_parties/backend/manifest.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from pathlib import Path + +from govoplan_core.core.institutional import CAPABILITY_PARTY_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_parties.backend.db import models as party_models +from govoplan_parties.backend.service import SqlPartyResolver + + +MODULE_ID = "parties" +MODULE_NAME = "Parties" +MODULE_VERSION = "0.1.14" +READ_SCOPE = "parties:procedure:read" +WRITE_SCOPE = "parties:procedure:write" +ADMIN_SCOPE = "parties:procedure: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="Parties", level="tenant", module_id=module_id, resource=resource, action=action) + + +def _router(_context: ModuleContext): + from govoplan_parties.backend.router import router + + return router + + +def _resolver(_context: ModuleContext) -> SqlPartyResolver: + return SqlPartyResolver() + + +manifest = ModuleManifest( + id=MODULE_ID, + name=MODULE_NAME, + version=MODULE_VERSION, + optional_dependencies=("identity", "organizations", "addresses", "cases", "workflow_engine", "decisions", "policy", "audit"), + provides_interfaces=(ModuleInterfaceProvider(name="parties.procedure", version="0.1.0"), ModuleInterfaceProvider(name="parties.representation", version="0.1.0")), + permissions=( + _permission(READ_SCOPE, "View procedure parties", "View procedure-local roles, contact snapshots, and representation evidence."), + _permission(WRITE_SCOPE, "Manage procedure parties", "Create and revise procedure parties and revoke representation powers."), + _permission(ADMIN_SCOPE, "Administer procedure parties", "Administer party lifecycle, access, and recovery."), + ), + role_templates=( + RoleTemplate(slug="party_manager", name="Party manager", description="Manage procedure-local parties and representation.", permissions=(READ_SCOPE, WRITE_SCOPE)), + RoleTemplate(slug="party_reader", name="Party reader", description="Inspect procedure parties and authority evidence.", permissions=(READ_SCOPE,)), + ), + route_factory=_router, + capability_factories={CAPABILITY_PARTY_RESOLVER: _resolver}, + capability_documentation={CAPABILITY_PARTY_RESOLVER: CapabilityDocumentation(label="Procedure Party resolver", summary="Returns effective, tenant-bound procedure parties and representation powers.", 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(party_models.ProcedurePartyRevision, label="Parties"), + retirement_notes="Destructive retirement requires a database snapshot and removes procedure Party history.", + ), + uninstall_guard_providers=(persistent_table_uninstall_guard(party_models.ProcedurePartyRevision, label="Parties"),), + documentation=( + DocumentationTopic( + id="parties.procedure-and-representation", + title="Procedure parties and representation", + summary="Keep procedure roles and authority distinct from subject master data.", + body="Parties stores immutable procedure participation and explicit representation powers. Powers cannot disappear silently and delivery uses frozen contact snapshots.", + layer="configured", + documentation_types=("admin", "user"), + audience=("user", "operator", "module_admin", "auditor"), + links=(DocumentationLink(label="Parties domain and recovery", href="govoplan-parties/docs/PARTIES_DOMAIN.md", kind="repository"),), + ), + ), + architecture=declared_module_architecture( + layer="human_work_procedure", + kind="domain", + maturity="vertical_slice", + documentation_ref="docs/PARTIES_DOMAIN.md", + test_ref="tests/test_parties.py", + known_limits=("No dedicated WebUI is included; procedure modules present Party data in context.",), + supported_authority_modes=("native_authoritative",), + owned_concepts=("procedure party", "representation power", "procedure delivery authority"), + non_owned_concepts=("identity master", "organization master", "contact point", "case lifecycle"), + reference_packages=("product.service-to-decision",), + migration_docs=("docs/PARTIES_DOMAIN.md",), + recovery_docs=("docs/PARTIES_DOMAIN.md",), + security_docs=("docs/PARTIES_DOMAIN.md",), + operations_docs=("docs/PARTIES_DOMAIN.md",), + ), +) + + +def get_manifest() -> ModuleManifest: + return manifest diff --git a/src/govoplan_parties/backend/migrations/__init__.py b/src/govoplan_parties/backend/migrations/__init__.py new file mode 100644 index 0000000..c03c08b --- /dev/null +++ b/src/govoplan_parties/backend/migrations/__init__.py @@ -0,0 +1 @@ +"""Parties migrations.""" diff --git a/src/govoplan_parties/backend/migrations/versions/__init__.py b/src/govoplan_parties/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..3b6f457 --- /dev/null +++ b/src/govoplan_parties/backend/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Parties migration revisions.""" diff --git a/src/govoplan_parties/backend/migrations/versions/c0d3e4f5a6b7_v0114_parties_baseline.py b/src/govoplan_parties/backend/migrations/versions/c0d3e4f5a6b7_v0114_parties_baseline.py new file mode 100644 index 0000000..ef1d4ea --- /dev/null +++ b/src/govoplan_parties/backend/migrations/versions/c0d3e4f5a6b7_v0114_parties_baseline.py @@ -0,0 +1,49 @@ +"""v0.1.14 Parties baseline. + +Revision ID: c0d3e4f5a6b7 +Revises: None +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "c0d3e4f5a6b7" +down_revision = None +branch_labels = None +depends_on = "4f2a9c8e7b6d" + + +def upgrade() -> None: + op.create_table( + "procedure_party_revisions", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("party_id", sa.String(length=255), nullable=False), + sa.Column("procedure_kind", sa.String(length=30), nullable=False), + sa.Column("procedure_owner_module", sa.String(length=80), nullable=False), + sa.Column("procedure_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"], ["procedure_party_revisions.id"], name=op.f("fk_procedure_party_revisions_previous_revision_id_procedure_party_revisions"), ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_procedure_party_revisions")), + sa.UniqueConstraint("tenant_id", "party_id", "revision", name="uq_procedure_party_revision"), + ) + for column in ("tenant_id", "party_id", "procedure_kind", "procedure_owner_module", "procedure_id", "previous_revision_id", "status", "recorded_at", "superseded_at", "created_by"): + op.create_index(op.f(f"ix_procedure_party_revisions_{column}"), "procedure_party_revisions", [column], unique=False) + op.create_index("ix_procedure_party_current", "procedure_party_revisions", ["tenant_id", "party_id", "superseded_at"], unique=False) + op.create_index("ix_procedure_party_resolution", "procedure_party_revisions", ["tenant_id", "procedure_kind", "procedure_owner_module", "procedure_id", "status", "valid_from", "valid_to"], unique=False) + + +def downgrade() -> None: + op.drop_table("procedure_party_revisions") diff --git a/src/govoplan_parties/backend/router.py b/src/govoplan_parties/backend/router.py new file mode 100644 index 0000000..d420698 --- /dev/null +++ b/src/govoplan_parties/backend/router.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, 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_parties.backend.manifest import READ_SCOPE, WRITE_SCOPE +from govoplan_parties.backend.schemas import PartyListResponse, PartyResolutionRequest, PartyWriteRequest +from govoplan_parties.backend.service import ( + PartyStoreError, + SqlPartyResolver, + get_procedure_party, + party_from_mapping, + record_procedure_party, + reference_from_mapping, +) + + +router = APIRouter(prefix="/parties", tags=["parties"]) + + +def _require(principal: ApiPrincipal, scope: str) -> None: + if not has_scope(principal, scope): + raise HTTPException(status_code=403, detail=f"Missing scope: {scope}") + + +def _error(exc: Exception) -> HTTPException: + message = str(exc) + return HTTPException(status_code=409 if "conflict" in message.lower() else 400, detail=message) + + +@router.get("/{party_id}", response_model=dict[str, Any]) +def api_get_party( + party_id: str, + revision: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, Any]: + _require(principal, READ_SCOPE) + item = get_procedure_party(session, principal, party_id=party_id, revision=revision) + if item is None: + raise HTTPException(status_code=404, detail="Procedure Party not found") + return item.to_dict(include_inspection=True) + + +@router.post("", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED) +def api_record_party( + payload: PartyWriteRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, Any]: + _require(principal, WRITE_SCOPE) + try: + item = record_procedure_party( + session, + principal, + party=party_from_mapping(payload.party), + expected_revision=payload.expected_revision, + ) + session.commit() + except (PartyStoreError, InstitutionalContextError) as exc: + session.rollback() + raise _error(exc) from exc + return item.to_dict(include_inspection=True) + + +@router.post("/resolve", response_model=PartyListResponse) +def api_resolve_parties( + payload: PartyResolutionRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> PartyListResponse: + _require(principal, READ_SCOPE) + try: + items = SqlPartyResolver().list_procedure_parties( + session, + principal, + procedure_ref=reference_from_mapping(payload.procedure_ref), + effective_at=payload.effective_at, + ) + except (PartyStoreError, InstitutionalContextError) as exc: + raise _error(exc) from exc + return PartyListResponse(parties=[item.to_dict(include_inspection=True) for item in items]) + + +__all__ = ["router"] diff --git a/src/govoplan_parties/backend/schemas.py b/src/govoplan_parties/backend/schemas.py new file mode 100644 index 0000000..c495860 --- /dev/null +++ b/src/govoplan_parties/backend/schemas.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class PartyWriteRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + party: dict[str, Any] + expected_revision: str | None = Field(default=None, max_length=120) + + +class PartyResolutionRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + procedure_ref: dict[str, Any] + effective_at: datetime | None = None + + +class PartyListResponse(BaseModel): + parties: list[dict[str, Any]] + + +__all__ = ["PartyListResponse", "PartyResolutionRequest", "PartyWriteRequest"] diff --git a/src/govoplan_parties/backend/service.py b/src/govoplan_parties/backend/service.py new file mode 100644 index 0000000..4c85e51 --- /dev/null +++ b/src/govoplan_parties/backend/service.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any, Mapping, Sequence + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from govoplan_core.core.institutional import ( + InstitutionalContextError, + InstitutionalReference, + PartyRepresentation, + ProcedureParty, + TemporalRevision, + revise_procedure_party, + revoke_party_representation, +) +from govoplan_parties.backend.db.models import ProcedurePartyRevision + + +MAX_PARTY_CANDIDATES = 1_000 + + +class PartyStoreError(ValueError): + pass + + +def record_procedure_party( + session: Session, + principal: object, + *, + party: ProcedureParty, + expected_revision: str | None = None, +) -> ProcedureParty: + tenant_id = _principal_tenant(principal) + _validate_party(party, tenant_id=tenant_id) + payload = party.to_dict(include_inspection=True) + replay = ( + session.query(ProcedurePartyRevision) + .filter( + ProcedurePartyRevision.tenant_id == tenant_id, + ProcedurePartyRevision.party_id == party.reference.object_id, + ProcedurePartyRevision.revision == party.temporal.revision, + ) + .one_or_none() + ) + if replay is not None: + if replay.payload != payload: + raise PartyStoreError( + "A different Party payload already uses this revision." + ) + return _party_from_row(replay) + + current_row = _current_row( + session, + tenant_id=tenant_id, + party_id=party.reference.object_id, + lock=True, + ) + _validate_temporal(party.temporal) + if current_row is None: + if expected_revision is not None: + raise PartyStoreError("Party revision conflict: no current revision exists.") + _validate_new_representations(party.representations) + else: + current = _party_from_row(current_row) + if not _same_reference_identity(current.procedure_ref, party.procedure_ref): + raise PartyStoreError("A Party cannot move to another procedure.") + try: + revised = revise_procedure_party( + current, + expected_revision=expected_revision or "", + temporal=party.temporal, + status=party.status, + role=party.role, + subject=party.subject, + preferred_channels=party.preferred_channels, + permitted_channels=party.permitted_channels, + delivery_recipient=party.delivery_recipient, + representations=party.representations, + contact_snapshot_refs=party.contact_snapshot_refs, + evidence=party.evidence, + ) + except InstitutionalContextError as exc: + raise PartyStoreError(str(exc)) from exc + if revised != party: + raise PartyStoreError( + "Party identity and immutable procedure reference must follow the lifecycle revision." + ) + _validate_representation_changes(current.representations, party.representations) + current_row.superseded_at = party.temporal.recorded_at + + row = ProcedurePartyRevision( + tenant_id=tenant_id, + party_id=party.reference.object_id, + procedure_kind=party.procedure_ref.kind, + procedure_owner_module=party.procedure_ref.owner_module, + procedure_id=party.procedure_ref.object_id, + revision=party.temporal.revision, + previous_revision_id=current_row.id if current_row is not None else None, + status=party.status, + valid_from=party.temporal.valid_from, + valid_to=party.temporal.valid_to, + recorded_at=_recorded_at(party.temporal), + payload=payload, + created_by=_principal_actor(principal), + ) + session.add(row) + session.flush() + return _party_from_row(row) + + +def get_procedure_party( + session: Session, + principal: object, + *, + party_id: str, + revision: str | None = None, +) -> ProcedureParty | None: + tenant_id = _principal_tenant(principal) + query = session.query(ProcedurePartyRevision).filter( + ProcedurePartyRevision.tenant_id == tenant_id, + ProcedurePartyRevision.party_id == party_id, + ) + if revision is None: + query = query.filter(ProcedurePartyRevision.superseded_at.is_(None)) + else: + query = query.filter(ProcedurePartyRevision.revision == revision) + row = query.order_by(ProcedurePartyRevision.recorded_at.desc()).first() + return _party_from_row(row) if row is not None else None + + +class SqlPartyResolver: + def list_procedure_parties( + self, + session: object, + principal: object, + *, + procedure_ref: InstitutionalReference, + effective_at: datetime | None = None, + ) -> Sequence[ProcedureParty]: + tenant_id = _principal_tenant(principal) + if procedure_ref.tenant_id != tenant_id or procedure_ref.kind not in {"case", "workflow", "decision"}: + raise InstitutionalContextError( + "Party resolution requires a same-tenant case, workflow, or decision reference." + ) + typed_session = _session(session) + query = typed_session.query(ProcedurePartyRevision).filter( + ProcedurePartyRevision.tenant_id == tenant_id, + ProcedurePartyRevision.procedure_kind == procedure_ref.kind, + ProcedurePartyRevision.procedure_owner_module == procedure_ref.owner_module, + ProcedurePartyRevision.procedure_id == procedure_ref.object_id, + ) + if effective_at is None: + query = query.filter(ProcedurePartyRevision.superseded_at.is_(None)) + else: + query = query.filter( + or_(ProcedurePartyRevision.valid_from.is_(None), ProcedurePartyRevision.valid_from <= effective_at), + or_(ProcedurePartyRevision.valid_to.is_(None), ProcedurePartyRevision.valid_to > effective_at), + ) + rows = query.order_by( + ProcedurePartyRevision.party_id.asc(), + ProcedurePartyRevision.recorded_at.desc(), + ).limit(MAX_PARTY_CANDIDATES + 1).all() + if len(rows) > MAX_PARTY_CANDIDATES: + raise InstitutionalContextError("Party resolution candidate bound was exceeded.") + latest: dict[str, ProcedureParty] = {} + for row in rows: + latest.setdefault(row.party_id, _party_from_row(row)) + return tuple(latest.values()) + + +def party_from_mapping(value: Mapping[str, object]) -> ProcedureParty: + try: + return ProcedureParty.from_mapping(value) + except InstitutionalContextError as exc: + raise PartyStoreError(str(exc)) from exc + + +def reference_from_mapping(value: Mapping[str, object]) -> InstitutionalReference: + try: + return InstitutionalReference.from_mapping(value) + except InstitutionalContextError as exc: + raise PartyStoreError(str(exc)) from exc + + +def _validate_representation_changes( + current: Sequence[PartyRepresentation], + revised: Sequence[PartyRepresentation], +) -> None: + next_by_key = {_representation_key(item): item for item in revised} + if len(next_by_key) != len(revised): + raise PartyStoreError("Party representations cannot contain duplicate powers.") + current_keys = {_representation_key(item) for item in current} + if not current_keys.issubset(next_by_key): + raise PartyStoreError( + "Representation powers cannot be removed; revoke them explicitly." + ) + for previous in current: + next_item = next_by_key[_representation_key(previous)] + if next_item == previous: + continue + if previous.revoked_at is not None: + raise PartyStoreError("A revoked representation power is immutable.") + if next_item.revoked_at is None: + raise PartyStoreError( + "An existing representation power can only change through revocation." + ) + try: + expected = revoke_party_representation( + previous, + expected_revision=previous.temporal.revision, + temporal=next_item.temporal, + revoked_at=next_item.revoked_at, + evidence=next_item.evidence, + ) + except InstitutionalContextError as exc: + raise PartyStoreError(str(exc)) from exc + if expected != next_item: + raise PartyStoreError( + "Representation revocation cannot rewrite parties, power, or permitted actions." + ) + _validate_new_representations( + tuple(item for item in revised if _representation_key(item) not in current_keys) + ) + + +def _validate_new_representations(items: Sequence[PartyRepresentation]) -> None: + seen: set[tuple[str, str, str]] = set() + for item in items: + key = _representation_key(item) + if key in seen: + raise PartyStoreError("Party representations cannot contain duplicate powers.") + seen.add(key) + _validate_temporal(item.temporal) + if item.revoked_at is not None: + raise PartyStoreError("A new representation cannot already be revoked.") + + +def _representation_key(item: PartyRepresentation) -> tuple[str, str, str]: + return ( + item.power_ref, + item.representative_party_ref.object_id, + item.represented_party_ref.object_id, + ) + + +def _current_row(session: Session, *, tenant_id: str, party_id: str, lock: bool) -> ProcedurePartyRevision | None: + query = session.query(ProcedurePartyRevision).filter( + ProcedurePartyRevision.tenant_id == tenant_id, + ProcedurePartyRevision.party_id == party_id, + ProcedurePartyRevision.superseded_at.is_(None), + ) + if lock: + query = query.with_for_update() + return query.one_or_none() + + +def _party_from_row(row: ProcedurePartyRevision) -> ProcedureParty: + 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 ProcedureParty.from_mapping(payload) + + +def _validate_party(party: ProcedureParty, *, tenant_id: str) -> None: + if party.reference.owner_module != "parties": + raise PartyStoreError("Procedure Parties must be owned by Parties.") + if party.reference.tenant_id != tenant_id: + raise PartyStoreError("Procedure Parties cannot cross tenants.") + if party.reference.version != party.temporal.revision: + raise PartyStoreError("Party reference version must match its temporal revision.") + if party.temporal.superseded_at is not None: + raise PartyStoreError("Clients cannot set Party superseded_at.") + + +def _validate_temporal(temporal: TemporalRevision) -> None: + _recorded_at(temporal) + if not str(temporal.change_reason or "").strip(): + raise PartyStoreError("A Party revision requires recorded_at and change_reason.") + + +def _recorded_at(temporal: TemporalRevision) -> datetime: + if temporal.recorded_at is None: + raise PartyStoreError("A Party revision requires recorded_at.") + return temporal.recorded_at + + +def _same_reference_identity(left: InstitutionalReference, right: InstitutionalReference) -> bool: + return (left.kind, left.owner_module, left.object_id, left.tenant_id) == (right.kind, right.owner_module, right.object_id, right.tenant_id) + + +def _principal_tenant(principal: object) -> str: + tenant_id = str(getattr(principal, "tenant_id", "") or "").strip() + if not tenant_id: + raise InstitutionalContextError("Party 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("Party 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__ = ["PartyStoreError", "SqlPartyResolver", "get_procedure_party", "party_from_mapping", "record_procedure_party", "reference_from_mapping"] diff --git a/src/govoplan_parties/py.typed b/src/govoplan_parties/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_parties/py.typed @@ -0,0 +1 @@ + diff --git a/tests/test_parties.py b/tests/test_parties.py new file mode 100644 index 0000000..91e941c --- /dev/null +++ b/tests/test_parties.py @@ -0,0 +1,92 @@ +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 ( + EvidenceReference, + InstitutionalReference, + PartyRepresentation, + PartySubjectReference, + ProcedureParty, + TemporalRevision, +) +from govoplan_parties.backend.db.models import ProcedurePartyRevision +from govoplan_parties.backend.service import PartyStoreError, SqlPartyResolver, record_procedure_party + + +NOW = datetime(2026, 8, 1, 11, 0, tzinfo=UTC) + + +@dataclass +class Principal: + tenant_id: str = "tenant-1" + account_id: str = "account-1" + + +def ref(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 representation(*, revision: str = "1", revoked_at: datetime | None = None) -> PartyRepresentation: + return PartyRepresentation( + representative_party_ref=ref("party", "representative", "parties", "1"), + represented_party_ref=ref("party", "applicant", "parties", "1"), + power_ref="power-1", + permitted_actions=("receive",), + temporal=TemporalRevision(revision=revision, valid_from=NOW, recorded_at=NOW + timedelta(minutes=int(revision) - 1), change_reason="Power recorded." if revision == "1" else "Power revoked."), + evidence=(EvidenceReference(kind="document", owner_module="files", evidence_id="power.pdf", tenant_id="tenant-1", version=revision),), + revoked_at=revoked_at, + ) + + +def party(*, revision: str = "1", representations: tuple[PartyRepresentation, ...] | None = None) -> ProcedureParty: + return ProcedureParty( + reference=ref("party", "representative", "parties", revision), + procedure_ref=ref("case", "case-1", "cases"), + role="representative", + subject=PartySubjectReference(kind="identity", provider="identity", subject_id="person-1", tenant_id="tenant-1", version="1"), + temporal=TemporalRevision(revision=revision, valid_from=NOW, recorded_at=NOW + timedelta(minutes=int(revision) - 1), change_reason="Party recorded." if revision == "1" else "Representation changed."), + permitted_channels=("postbox",), + preferred_channels=("postbox",), + delivery_recipient=True, + representations=representations if representations is not None else (representation(),), + contact_snapshot_refs=("addresses:snapshot-1",), + ) + + +class PartyTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite+pysqlite:///:memory:") + ProcedurePartyRevision.__table__.create(self.engine) + self.session = Session(self.engine) + self.principal = Principal() + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def test_resolve_and_explicit_representation_revocation(self) -> None: + record_procedure_party(self.session, self.principal, party=party()) + resolved = SqlPartyResolver().list_procedure_parties(self.session, self.principal, procedure_ref=ref("case", "case-1", "cases"), effective_at=NOW) + self.assertEqual(1, len(resolved)) + + revoked = representation(revision="2", revoked_at=NOW + timedelta(hours=1)) + revised = party(revision="2", representations=(revoked,)) + record_procedure_party(self.session, self.principal, party=revised, expected_revision="1") + self.assertEqual("2", SqlPartyResolver().list_procedure_parties(self.session, self.principal, procedure_ref=ref("case", "case-1", "cases"))[0].temporal.revision) + + def test_representation_cannot_disappear_or_cross_tenants(self) -> None: + record_procedure_party(self.session, self.principal, party=party()) + with self.assertRaisesRegex(PartyStoreError, "cannot be removed"): + record_procedure_party(self.session, self.principal, party=party(revision="2", representations=()), expected_revision="1") + with self.assertRaisesRegex(Exception, "same-tenant"): + SqlPartyResolver().list_procedure_parties(self.session, Principal("tenant-2"), procedure_ref=ref("case", "case-1", "cases")) + + +if __name__ == "__main__": + unittest.main()