feat: implement procedure party registry

This commit is contained in:
2026-08-01 17:48:37 +02:00
commit 87c705b30e
24 changed files with 1310 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/
+15
View File
@@ -0,0 +1,15 @@
# GovOPlaN Parties
<!-- govoplan-repository-type:start -->
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
`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).
+25
View File
@@ -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.
+21
View File
@@ -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"
+3
View File
@@ -0,0 +1,3 @@
"""GovOPlaN Parties module."""
__version__ = "0.1.14"
+1
View File
@@ -0,0 +1 @@
"""Parties backend."""
@@ -0,0 +1,3 @@
from govoplan_parties.backend.db.models import ProcedurePartyRevision
__all__ = ["ProcedurePartyRevision"]
+46
View File
@@ -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"]
+96
View File
@@ -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
@@ -0,0 +1 @@
"""Parties migrations."""
@@ -0,0 +1 @@
"""Parties migration revisions."""
@@ -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")
+90
View File
@@ -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"]
+27
View File
@@ -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"]
+323
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
+92
View File
@@ -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()