125 Commits
Author SHA1 Message Date
zemion 2b4eb0151f Add governed institutional capability contracts 2026-08-01 20:57:25 +02:00
zemion 7192d32e65 feat: add institutional governance and recovery contracts 2026-08-01 17:46:54 +02:00
zemion b65b48832b Add cross-module operational and interaction contracts 2026-07-31 22:48:07 +02:00
zemion 4cb334c912 Add distribution audience contracts and module wiring 2026-07-31 20:59:49 +02:00
zemion 6ebb299d6c Add workflow baseline orchestration contracts 2026-07-31 19:39:59 +02:00
zemion 42b5019464 Extend Postbox message authoring contract 2026-07-31 18:40:30 +02:00
zemion 4c0e6435ab Fix IDM lifecycle capability identifier 2026-07-31 18:40:30 +02:00
zemion e37d8fee94 Extend Postbox access classification contract 2026-07-31 18:21:36 +02:00
zemion 50a8d459e7 Add IDM assignment lifecycle worker contract 2026-07-31 18:07:36 +02:00
zemion 5ee85d07d6 Define governed View projection contract 2026-07-31 17:52:57 +02:00
zemion cf01545806 Allow session-aware definition policy resolution 2026-07-31 17:34:16 +02:00
zemion 1884274f8d feat: add workflow engine contribution contracts 2026-07-31 16:58:57 +02:00
zemion 5211e07d0b fix: enforce DataGrid cover boundary 2026-07-31 04:44:24 +02:00
zemion 7b8072d049 feat: complete shared platform UI contracts 2026-07-31 04:21:34 +02:00
zemion 5b55f59a92 Add shared automation and WebUI editing primitives 2026-07-31 02:48:56 +02:00
zemion f0898fcdee feat: add governed ownership workflows and admin tree navigation 2026-07-30 17:42:05 +02:00
zemion 9b88ae388b feat: harden shared platform contracts 2026-07-30 14:26:36 +02:00
zemion 6970bf7457 Support observable grid queries and file filters 2026-07-30 05:22:09 +02:00
zemion 47e106684d perf(webui): lazily load module descriptors 2026-07-30 04:35:57 +02:00
zemion 9e219bc4d3 feat(core): dispatch bounded postbox routes 2026-07-30 03:59:38 +02:00
zemion ea436a513f feat(core): define organization hierarchy contracts 2026-07-30 03:26:19 +02:00
zemion e7c84e3227 feat(core): reconcile durable workflow instances 2026-07-30 03:09:58 +02:00
zemion cf7afe9dda feat(core): dispatch durable dataflow runs 2026-07-30 02:32:49 +02:00
zemion ca8a8c5111 feat(core): define sanctions screening gate contract 2026-07-30 01:54:58 +02:00
zemion f3b388fe7e perf(core): define bounded reference search 2026-07-30 01:29:45 +02:00
zemion af3e0a055d perf(core): batch tenant summary providers 2026-07-30 01:15:04 +02:00
zemion 51d4032b86 docs(core): define compatibility retention policy 2026-07-30 01:03:39 +02:00
zemion 0beb9ffea9 build: reject duplicate generated translations 2026-07-30 00:40:25 +02:00
zemion 9e6a6b5fdc feat(webui): center optional global search 2026-07-29 22:00:45 +02:00
zemion 48fb953b93 feat: add durable auth principal cache revisions 2026-07-29 19:23:52 +02:00
zemion 4bde0495f7 feat: expose workflow view resolution input 2026-07-29 19:09:03 +02:00
zemion a80caf7933 feat: support focused dev reload scopes 2026-07-29 18:52:55 +02:00
zemion 790790ab37 feat: expose sanctions screening integration 2026-07-29 18:46:53 +02:00
zemion 920e3c9834 feat: version search source contracts 2026-07-29 18:08:52 +02:00
zemion 13893c80cd feat: version automation principal subjects 2026-07-29 17:48:36 +02:00
zemion a192a2215f Add durable platform event delivery contract 2026-07-29 17:34:52 +02:00
zemion e8fed6d25a fix: resolve postbox inbox icon 2026-07-29 16:01:45 +02:00
zemion d9b5708df0 feat: add search and external integration contracts 2026-07-29 15:50:08 +02:00
zemion 68328f3d8e feat: strengthen module contracts and shared WebUI runtime 2026-07-29 14:16:28 +02:00
zemion 53e947935a fix: standardize direct page scroll viewports 2026-07-28 22:50:11 +02:00
zemion 324c26da78 fix: allow fallback dashboard scrolling 2026-07-28 22:13:22 +02:00
zemion 389f98e349 Keep normalized View roots acyclic 2026-07-28 21:32:20 +02:00
zemion ce9ef8d88f Add governed View surface runtime 2026-07-28 21:04:54 +02:00
zemion 13bc3d3b4e Add shared credential envelope infrastructure 2026-07-28 19:32:41 +02:00
zemion 3f5870281a Color all shared alert tones 2026-07-28 18:35:53 +02:00
zemion a46df85479 Resolve XyFlow styles for linked modules 2026-07-28 15:47:15 +02:00
zemion c31581b1b9 Prebundle XyFlow for linked module development 2026-07-28 15:39:00 +02:00
zemion 26ae034153 Add governed automation contracts 2026-07-28 15:02:42 +02:00
zemion baa2143a26 feat: add dataflow publication contracts and workflow webui 2026-07-28 13:47:50 +02:00
zemion 8b1910b5b7 feat: add datasource and definition graph contracts 2026-07-28 12:42:49 +02:00
zemion d36bb94335 Add provider-neutral tabular source contracts 2026-07-28 11:12:24 +02:00
zemion 74034947c6 Update vulnerable PostCSS dependency 2026-07-28 01:36:39 +02:00
zemion c7183fe7f1 Integrate Dataflow module into core 2026-07-28 01:33:37 +02:00
zemion 139a352c80 chore: update GovOPlaN repository references 2026-07-27 15:46:51 +02:00
zemion 336c94137f chore(release): align Mail bundle with Campaign contract 2026-07-23 00:47:34 +02:00
zemion 93225b6487 docs: move system status badges to meta repository 2026-07-22 23:45:42 +02:00
zemion e11ea81008 chore(release): prepare Core 0.1.14 2026-07-22 20:31:49 +02:00
zemion bc8afeb139 test(campaign): align synchronous send security contract 2026-07-22 20:30:00 +02:00
zemion f876345656 test(db): prove PostgreSQL retirement atomicity 2026-07-22 15:28:00 +02:00
zemion d487726f4d chore(release): bump Core to 0.1.13 2026-07-22 10:40:27 +02:00
zemion e6fc07da37 chore(release): bundle Campaign 0.1.10 2026-07-22 10:38:37 +02:00
zemion e6d589eb07 fix(release): package Core migration runtime 2026-07-22 10:34:34 +02:00
zemion 59610e21d2 chore(release): record reviewed 0.1.12 migration heads 2026-07-22 09:06:56 +02:00
zemion cece71d945 feat(webui): synchronize external DataGrid queries 2026-07-22 09:03:11 +02:00
zemion 22e8183846 fix(webui): translate MetricCard content 2026-07-22 08:48:42 +02:00
zemion aa111a5fe1 chore(release): bump Core to 0.1.12 2026-07-22 08:41:54 +02:00
zemion e6062fe9e4 fix(webui): enforce full-result DataGrid queries 2026-07-22 08:05:11 +02:00
zemion 987ca894ed chore(release): record reviewed 0.1.11 migration heads 2026-07-22 04:42:30 +02:00
zemion 4caa326878 chore(core): bump version to 0.1.11 2026-07-22 03:41:24 +02:00
zemion 8c4c4456c6 feat(core): define auditable poll response retirement 2026-07-22 03:31:05 +02:00
zemion 6abe292ac8 feat(core): define governed poll participation contract 2026-07-22 03:21:03 +02:00
zemion fea2807754 feat(core): define atomic poll option ordering 2026-07-22 03:20:28 +02:00
zemion 22646c614c feat(core): add bounded people picker foundation 2026-07-22 03:01:56 +02:00
zemion 17376332a2 feat(core): configure bounded scheduling cancellation notices 2026-07-22 02:58:09 +02:00
zemion 0946bc84a9 feat(core): support explicit public module routes 2026-07-22 02:58:03 +02:00
zemion a18499cbb5 feat(core): require scoped user workflows 2026-07-22 01:55:24 +02:00
zemion 36d7b73bb5 fix(webui): allow card content to overflow 2026-07-22 01:49:24 +02:00
zemion b89a2d15f1 chore(core): bump version to 0.1.10 2026-07-21 20:47:54 +02:00
zemion 7f923afdad docs(core): refine function-bound postbox encryption 2026-07-21 20:47:54 +02:00
zemion a7683c5d4a feat(core): add resilient fixed-window throttling 2026-07-21 20:47:54 +02:00
zemion 41ad057f7e feat(webui): standardize discard and table actions 2026-07-21 20:47:54 +02:00
zemion bf0729eb59 feat(core): add authenticated baseline role templates 2026-07-21 20:47:54 +02:00
zemion c4b90181e0 fix(webui): localize contextual Mail help 2026-07-21 19:15:56 +02:00
zemion 55ed194a99 fix(webui): respect configured documentation access 2026-07-21 19:01:00 +02:00
zemion b3b0cf0fca feat(webui): open contextual configured handbooks 2026-07-21 18:42:31 +02:00
zemion fa9119bea7 security(webui): block remote mail preview content 2026-07-21 17:52:02 +02:00
zemion 70ca772138 test: gate Mail on Campaign access interface 2026-07-21 17:51:35 +02:00
zemion 2eae5c4df6 test: align module contracts with Mail-owned delivery 2026-07-21 17:15:19 +02:00
zemion 57fe6c6006 security(connectors): reject tunneled metadata addresses 2026-07-21 16:54:10 +02:00
zemion 713afdb39b fix(installer): enlist table retirement transaction 2026-07-21 16:44:20 +02:00
zemion 77f8d15d17 docs: clarify fail-closed connector SDKs 2026-07-21 15:53:22 +02:00
zemion fda99d40eb docs: clarify institutional identity ownership 2026-07-21 15:46:55 +02:00
zemion 5ab1af803b docs: align technical roadmap to reference program 2026-07-21 15:45:39 +02:00
zemion 0845e99cf6 Block SDK-managed secondary connector peers 2026-07-21 15:43:04 +02:00
zemion 28a0a596a6 Fail closed for unpinned connector transports 2026-07-21 15:36:32 +02:00
zemion ae74189588 fix(webui): complete central admin control styles 2026-07-21 14:01:56 +02:00
zemion 09b5009187 feat(webui): extend shared explorer tree actions 2026-07-21 13:58:38 +02:00
zemion 2ca61059dc refactor(webui): describe organization function actions 2026-07-21 13:51:19 +02:00
zemion 865901f090 Centralize shared layout style contracts 2026-07-21 13:46:59 +02:00
zemion 2ac1e64daa refactor(webui): share outside-dismiss behavior 2026-07-21 13:35:09 +02:00
zemion 7526c5ebb2 fix(webui): centralize form control layout 2026-07-21 13:31:32 +02:00
zemion 8e1f64c790 feat(webui): add central selection list 2026-07-21 13:30:39 +02:00
zemion 66e4783d2e feat(webui): add central icon button 2026-07-21 13:23:59 +02:00
zemion 7af86b42eb Centralize explorer work-surface styling 2026-07-21 13:19:20 +02:00
zemion ad202f1267 Centralize shared small-note styling 2026-07-21 13:19:20 +02:00
zemion 6526f37aae feat(webui): centralize resource access explanations 2026-07-21 13:18:30 +02:00
zemion 9dabd9356d Document Core test bootstrap lint exemptions 2026-07-21 13:11:24 +02:00
zemion 6502775bf7 Block connector limited-broadcast targets 2026-07-21 13:02:29 +02:00
zemion b2492b820f Harden private connector address validation 2026-07-21 12:55:01 +02:00
zemion 78d9ae48b2 feat(webui): centralize disabled button reasons 2026-07-21 12:38:45 +02:00
zemion 4cb3e94de3 feat(webui): expose central pagination bar 2026-07-21 12:36:49 +02:00
zemion 9131838b98 refactor(webui): generalize central selection lists 2026-07-21 12:34:35 +02:00
zemion 8e9eb6e1f5 feat(webui): centralize contextual table actions 2026-07-21 12:22:35 +02:00
zemion 249bf63eb8 fix(release): remove unavailable tagged webui packages 2026-07-21 12:22:07 +02:00
zemion 248e3dc70e test(release): isolate catalog contract projection 2026-07-21 12:21:38 +02:00
zemion 230ecf42b0 Enforce deployment security boundaries 2026-07-21 12:10:05 +02:00
zemion 825791e9b0 Harden outbound connector transports 2026-07-21 12:09:44 +02:00
zemion 7184b6cdd6 Dispose replaced migration database handles 2026-07-21 03:22:59 +02:00
zemion ea8c600dce Cover Notifications in WebUI permutations 2026-07-21 03:18:08 +02:00
zemion 1839693575 Manage nested dialogs through a central stack 2026-07-21 03:18:08 +02:00
zemion 183bf7aef0 Ignore generated file drop test builds 2026-07-21 03:18:08 +02:00
zemion 37a5dfb182 Use managed Files in Campaign smoke fixtures 2026-07-21 03:18:08 +02:00
zemion 844f934379 Redact devserver database credentials 2026-07-21 03:18:08 +02:00
zemion a98475f7bc Harden installer runtime secrets 2026-07-21 03:18:08 +02:00
zemion 1153c9dd36 Clean Core security audit findings 2026-07-21 03:18:07 +02:00
283 changed files with 49983 additions and 2217 deletions
+2
View File
@@ -138,11 +138,13 @@ dist
# 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/
+2
View File
@@ -45,6 +45,8 @@ tools/checks/check-focused.sh
- Prefer `rg`, `sed`, and targeted tests over broad recursive scans or full builds.
- Avoid DataGrid changes unless explicitly requested; it is intentionally brittle and has known deferred work.
- Do not add module-to-module imports for optional integrations. Use core registry/capability/module metadata paths.
- Treat documentation as part of every behavior change. Update the owning module's manifest-driven `DocumentationTopic` contributions for each affected user and administrator workflow, setting, permission, limitation, and operational consequence. Feature modules own this content; `govoplan-docs` projects it and must not import feature internals.
- Keep a static user and administrator documentation baseline in every module manifest, even when richer configured-state topics come from `documentation_providers`. Run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after changing a manifest or module behavior.
- Treat Gitea issues as the canonical backlog and state log. Treat Gitea wiki pages as durable project context mirrored from repository and product docs. Use `docs/GITEA_ISSUES.md` for labels, templates, TODO import, wiki sync, and Codex issue updates.
- Do not keep generated WebUI test folders in git status; they should be ignored and removable.
- Do not start persistent dev servers unless the user asks.
+23 -8
View File
@@ -4,13 +4,6 @@
**Repository type:** system (kernel).
<!-- govoplan-repository-type:end -->
[![Module Matrix](https://git.add-ideas.de/add-ideas/govoplan/actions/workflows/module-matrix.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=module-matrix.yml&actor=0&status=0)
[![Release Integration](https://git.add-ideas.de/add-ideas/govoplan/actions/workflows/release-integration.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=release-integration.yml&actor=0&status=0)
[![Dependency Audit](https://git.add-ideas.de/add-ideas/govoplan/actions/workflows/dependency-audit.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=dependency-audit.yml&actor=0&status=0)
[![Security Audit](https://git.add-ideas.de/add-ideas/govoplan/actions/workflows/security-audit.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=security-audit.yml&actor=0&status=0)
# govoplan-core
GovOPlaN core is the platform runner and shared foundation. It owns the server entry point, database/session primitives, module discovery, migration orchestration, capability contracts, install/uninstall orchestration, and the shared WebUI shell. Platform and feature behavior is supplied by installed modules.
## Repository ownership
@@ -23,6 +16,9 @@ Core owns:
- kernel APIs for platform metadata, module lifecycle, health, and development diagnostics
- `@govoplan/core-webui`, including login, CSRF/API helpers, shell layout, generic UI components, IconRail, DataGrid, access boundaries, and module route/nav contracts
The shared DataGrid sizing and resize invariants are specified in
[`docs/DATAGRID_SIZING_CONTRACT.md`](docs/DATAGRID_SIZING_CONTRACT.md).
Platform and feature modules own their backend routers, models, migrations,
permissions, frontend packages, nav items, and route contributions. Access,
tenancy, policy, audit, and admin behavior live in their owning platform
@@ -54,7 +50,7 @@ python3 -m venv .venv
./.venv/bin/python -m pip install -r requirements-dev.txt
```
Run the platform server from core through the module-aware development runner. The default config reads `ENABLED_MODULES` and discovers installed module entry points. Local development defaults to `tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops`; set `ENABLED_MODULES` explicitly when testing a smaller module permutation.
Run the platform server from core through the module-aware development runner. The default config reads `ENABLED_MODULES` and discovers installed module entry points. Local development defaults to the modules listed by `govoplan_core.settings.Settings.enabled_modules`, including Views when its package is installed; set `ENABLED_MODULES` explicitly when testing a smaller module permutation.
```bash
cd /mnt/DATA/git/govoplan-core
@@ -74,6 +70,21 @@ ENABLED_MODULES=access,campaigns /mnt/DATA/git/govoplan/.venv/bin/python -m govo
The runner loads the same `GovoplanServerConfig` as `govoplan_core.server.app:app`, builds the platform registry, and passes core plus enabled module source roots to uvicorn as reload directories. After reinstalling the editable package, the same command is also available as `govoplan-devserver`.
For focused backend work, keep the complete module graph active while watching
only the module being edited. Core/config sources and explicit `--reload-dir`
paths remain watched:
```bash
/mnt/DATA/git/govoplan/.venv/bin/python -m govoplan_core.devserver \
--reload-module calendar \
--reload-module campaign
```
Use `--reload-core-only` when no optional module source tree should trigger a
restart. Omitting both options preserves the broad default and watches every
enabled module. Startup, migration, and compatibility checks still run against
the complete enabled graph whenever the backend restarts.
The default development database is PostgreSQL at `postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev`. Store the password in `~/.pgpass`. To force the disposable SQLite fallback, run with `GOVOPLAN_DEV_DATABASE_BACKEND=sqlite`; that database lives below `runtime/`.
Local devserver runs do not require Redis. `CELERY_ENABLED` defaults to `false`, so campaign queue actions update database state without publishing Celery tasks. Use the synchronous send flow for local send tests, or set `CELERY_ENABLED=true` only when a Redis broker and worker are running.
@@ -143,6 +154,10 @@ PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versio
The local host links sibling module WebUI packages through local file dependencies and Vite filesystem allowances. Release builds should use `webui/package.release.json`, which points WebUI module packages at tagged git refs. See [RELEASE_DEPENDENCIES.md](docs/RELEASE_DEPENDENCIES.md).
Production builds lazy-load enabled module descriptors and enforce initial and
asynchronous JavaScript budgets. See
[WEBUI_BUNDLE_BUDGETS.md](docs/WEBUI_BUNDLE_BUDGETS.md).
## Module contract
Backend modules register through the `govoplan.modules` entry point and return a `ModuleManifest`. A manifest can contribute:
@@ -0,0 +1,87 @@
"""add reusable core credential envelopes
Revision ID: c91f0a72be34
Revises: 0f1e2d3c4b5a
Create Date: 2026-07-23 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c91f0a72be34"
down_revision = "0f1e2d3c4b5a"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "core_credential_envelopes" in inspector.get_table_names():
return
op.create_table(
"core_credential_envelopes",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("scope_type", sa.String(length=20), nullable=False),
sa.Column("scope_id", sa.String(length=255), nullable=True),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("credential_kind", sa.String(length=40), nullable=False),
sa.Column("public_data", sa.JSON(), nullable=False),
sa.Column("secret_data_encrypted", sa.Text(), nullable=True),
sa.Column("secret_keys", sa.JSON(), nullable=False),
sa.Column("allowed_modules", sa.JSON(), nullable=False),
sa.Column("allowed_server_refs", sa.JSON(), nullable=False),
sa.Column("inherit_to_lower_scopes", sa.Boolean(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("revision", sa.String(length=36), nullable=False),
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
sa.Column("updated_by_user_id", sa.String(length=36), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["tenant_id"],
["core_scopes.id"],
name=op.f("fk_core_credential_envelopes_tenant_id_core_scopes"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_credential_envelopes")),
)
op.create_index(
"ix_core_credential_envelopes_scope",
"core_credential_envelopes",
["tenant_id", "scope_type", "scope_id"],
unique=False,
)
op.create_index(
"ix_core_credential_envelopes_active",
"core_credential_envelopes",
["tenant_id", "is_active", "deleted_at"],
unique=False,
)
for column in (
"tenant_id",
"scope_type",
"scope_id",
"credential_kind",
"is_active",
"created_by_user_id",
"updated_by_user_id",
"deleted_at",
):
op.create_index(
op.f(f"ix_core_credential_envelopes_{column}"),
"core_credential_envelopes",
[column],
unique=False,
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "core_credential_envelopes" in inspector.get_table_names():
op.drop_table("core_credential_envelopes")
@@ -0,0 +1,24 @@
"""development-track wrapper for generic ownership transfers."""
from __future__ import annotations
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
_path = (
Path(__file__).resolve().parents[1]
/ "versions"
/ "d03a7b9c1e5f_core_ownership_transfers.py"
)
_spec = spec_from_file_location("govoplan_core_ownership_transfer_migration", _path)
if _spec is None or _spec.loader is None:
raise RuntimeError(f"Unable to load ownership migration from {_path}")
_migration = module_from_spec(_spec)
_spec.loader.exec_module(_migration)
revision = _migration.revision
down_revision = _migration.down_revision
branch_labels = _migration.branch_labels
depends_on = _migration.depends_on
upgrade = _migration.upgrade
downgrade = _migration.downgrade
@@ -0,0 +1,24 @@
"""development-track wrapper for runtime coordination and recovery."""
from __future__ import annotations
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
_path = (
Path(__file__).resolve().parents[1]
/ "versions"
/ "e14b8c2d6f90_runtime_coordination_recovery.py"
)
_spec = spec_from_file_location("govoplan_runtime_coordination_migration", _path)
if _spec is None or _spec.loader is None:
raise RuntimeError(f"Unable to load runtime coordination migration from {_path}")
_migration = module_from_spec(_spec)
_spec.loader.exec_module(_migration)
revision = _migration.revision
down_revision = _migration.down_revision
branch_labels = _migration.branch_labels
depends_on = _migration.depends_on
upgrade = _migration.upgrade
downgrade = _migration.downgrade
+7 -1
View File
@@ -5,9 +5,15 @@ from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
try:
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate optional access metadata
except ModuleNotFoundError as exc:
if exc.name != "govoplan_access":
raise
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
from govoplan_core.core import ownership as core_ownership_models # noqa: F401 - populate core metadata
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
from govoplan_core.core.migrations import migration_metadata_plan
from govoplan_core.db.base import Base
from govoplan_core.server.default_config import get_server_config
@@ -0,0 +1,119 @@
"""add reusable core credential envelopes
Revision ID: c91f0a72be34
Revises: 4f2a9c8e7b6d
Create Date: 2026-07-23 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c91f0a72be34"
down_revision = "4f2a9c8e7b6d"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "core_credential_envelopes" in inspector.get_table_names():
return
op.create_table(
"core_credential_envelopes",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("scope_type", sa.String(length=20), nullable=False),
sa.Column("scope_id", sa.String(length=255), nullable=True),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("credential_kind", sa.String(length=40), nullable=False),
sa.Column("public_data", sa.JSON(), nullable=False),
sa.Column("secret_data_encrypted", sa.Text(), nullable=True),
sa.Column("secret_keys", sa.JSON(), nullable=False),
sa.Column("allowed_modules", sa.JSON(), nullable=False),
sa.Column("allowed_server_refs", sa.JSON(), nullable=False),
sa.Column("inherit_to_lower_scopes", sa.Boolean(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("revision", sa.String(length=36), nullable=False),
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
sa.Column("updated_by_user_id", sa.String(length=36), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["tenant_id"],
["core_scopes.id"],
name=op.f("fk_core_credential_envelopes_tenant_id_core_scopes"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_credential_envelopes")),
)
op.create_index(
"ix_core_credential_envelopes_scope",
"core_credential_envelopes",
["tenant_id", "scope_type", "scope_id"],
unique=False,
)
op.create_index(
"ix_core_credential_envelopes_active",
"core_credential_envelopes",
["tenant_id", "is_active", "deleted_at"],
unique=False,
)
op.create_index(
op.f("ix_core_credential_envelopes_tenant_id"),
"core_credential_envelopes",
["tenant_id"],
unique=False,
)
op.create_index(
op.f("ix_core_credential_envelopes_scope_type"),
"core_credential_envelopes",
["scope_type"],
unique=False,
)
op.create_index(
op.f("ix_core_credential_envelopes_scope_id"),
"core_credential_envelopes",
["scope_id"],
unique=False,
)
op.create_index(
op.f("ix_core_credential_envelopes_credential_kind"),
"core_credential_envelopes",
["credential_kind"],
unique=False,
)
op.create_index(
op.f("ix_core_credential_envelopes_is_active"),
"core_credential_envelopes",
["is_active"],
unique=False,
)
op.create_index(
op.f("ix_core_credential_envelopes_created_by_user_id"),
"core_credential_envelopes",
["created_by_user_id"],
unique=False,
)
op.create_index(
op.f("ix_core_credential_envelopes_updated_by_user_id"),
"core_credential_envelopes",
["updated_by_user_id"],
unique=False,
)
op.create_index(
op.f("ix_core_credential_envelopes_deleted_at"),
"core_credential_envelopes",
["deleted_at"],
unique=False,
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "core_credential_envelopes" in inspector.get_table_names():
op.drop_table("core_credential_envelopes")
@@ -0,0 +1,129 @@
"""add generic resource ownership transfer state
Revision ID: d03a7b9c1e5f
Revises: c91f0a72be34
Create Date: 2026-07-30 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "d03a7b9c1e5f"
down_revision = "c91f0a72be34"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "core_ownership_transfers" in inspector.get_table_names():
return
op.create_table(
"core_ownership_transfers",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("resource_module", sa.String(length=100), nullable=False),
sa.Column("resource_type", sa.String(length=100), nullable=False),
sa.Column("resource_id", sa.String(length=255), nullable=False),
sa.Column("kind", sa.String(length=40), nullable=False),
sa.Column("status", sa.String(length=50), nullable=False),
sa.Column("current_owner_type", sa.String(length=40), nullable=False),
sa.Column("current_owner_id", sa.String(length=255), nullable=False),
sa.Column("target_owner_type", sa.String(length=40), nullable=False),
sa.Column("target_owner_id", sa.String(length=255), nullable=False),
sa.Column("initiated_by_type", sa.String(length=40), nullable=False),
sa.Column("initiated_by_id", sa.String(length=255), nullable=False),
sa.Column("owner_approved_by_type", sa.String(length=40), nullable=True),
sa.Column("owner_approved_by_id", sa.String(length=255), nullable=True),
sa.Column("target_accepted_by_type", sa.String(length=40), nullable=True),
sa.Column("target_accepted_by_id", sa.String(length=255), nullable=True),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("assurance_profile", sa.String(length=80), nullable=True),
sa.Column("required_approvals", sa.Integer(), nullable=False),
sa.Column("approvals", sa.JSON(), nullable=False),
sa.Column("decisions", sa.JSON(), nullable=False),
sa.Column("idempotency_key", sa.String(length=200), nullable=False),
sa.Column("canonical_request_hash", sa.String(length=64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("execute_after", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("declined_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("expired_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_ownership_transfers")),
sa.UniqueConstraint(
"tenant_id",
"resource_module",
"idempotency_key",
name="uq_core_ownership_transfer_idempotency",
),
)
op.create_index(
op.f("ix_core_ownership_transfers_tenant_id"),
"core_ownership_transfers",
["tenant_id"],
unique=False,
)
op.create_index(
op.f("ix_core_ownership_transfers_kind"),
"core_ownership_transfers",
["kind"],
unique=False,
)
op.create_index(
op.f("ix_core_ownership_transfers_status"),
"core_ownership_transfers",
["status"],
unique=False,
)
op.create_index(
"ix_core_ownership_transfer_resource",
"core_ownership_transfers",
[
"tenant_id",
"resource_module",
"resource_type",
"resource_id",
"status",
],
unique=False,
)
op.create_index(
"ix_core_ownership_transfer_expiry",
"core_ownership_transfers",
["status", "expires_at"],
unique=False,
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "core_ownership_transfers" not in inspector.get_table_names():
return
op.drop_index(
"ix_core_ownership_transfer_expiry",
table_name="core_ownership_transfers",
)
op.drop_index(
"ix_core_ownership_transfer_resource",
table_name="core_ownership_transfers",
)
op.drop_index(
op.f("ix_core_ownership_transfers_status"),
table_name="core_ownership_transfers",
)
op.drop_index(
op.f("ix_core_ownership_transfers_kind"),
table_name="core_ownership_transfers",
)
op.drop_index(
op.f("ix_core_ownership_transfers_tenant_id"),
table_name="core_ownership_transfers",
)
op.drop_table("core_ownership_transfers")
@@ -0,0 +1,232 @@
"""add runtime coordination and recovery evidence
Revision ID: e14b8c2d6f90
Revises: d03a7b9c1e5f
Create Date: 2026-08-01 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e14b8c2d6f90"
down_revision = "d03a7b9c1e5f"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "core_runtime_nodes" not in tables:
op.create_table(
"core_runtime_nodes",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("installation_id", sa.String(length=100), nullable=False),
sa.Column("node_id", sa.String(length=200), nullable=False),
sa.Column("incarnation", sa.String(length=36), nullable=False),
sa.Column("role", sa.String(length=40), nullable=False),
sa.Column("software_version", sa.String(length=80), nullable=False),
sa.Column("composition_hash", sa.String(length=64), nullable=False),
sa.Column("queues", sa.JSON(), nullable=False),
sa.Column("state", sa.String(length=30), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_heartbeat_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("drain_requested_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("drain_reason", sa.String(length=500), nullable=True),
sa.Column("stopped_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_runtime_nodes")),
sa.UniqueConstraint(
"installation_id",
"node_id",
name="uq_core_runtime_node_installation_node",
),
)
for column in (
"installation_id",
"node_id",
"incarnation",
"role",
"composition_hash",
"state",
):
op.create_index(
op.f(f"ix_core_runtime_nodes_{column}"),
"core_runtime_nodes",
[column],
unique=False,
)
op.create_index(
"ix_core_runtime_nodes_installation_state_heartbeat",
"core_runtime_nodes",
["installation_id", "state", "last_heartbeat_at"],
unique=False,
)
if "core_distributed_leases" not in tables:
op.create_table(
"core_distributed_leases",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("installation_id", sa.String(length=100), nullable=False),
sa.Column("resource_key", sa.String(length=255), nullable=False),
sa.Column("holder_node_id", sa.String(length=200), nullable=True),
sa.Column("holder_incarnation", sa.String(length=36), nullable=True),
sa.Column("fencing_token", sa.BigInteger(), nullable=False),
sa.Column("acquired_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("renewed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_distributed_leases")),
sa.UniqueConstraint(
"installation_id",
"resource_key",
name="uq_core_distributed_lease_resource",
),
)
for column in (
"installation_id",
"resource_key",
"holder_node_id",
"holder_incarnation",
):
op.create_index(
op.f(f"ix_core_distributed_leases_{column}"),
"core_distributed_leases",
[column],
unique=False,
)
op.create_index(
"ix_core_distributed_leases_expiry",
"core_distributed_leases",
["installation_id", "expires_at"],
unique=False,
)
if "core_recovery_operations" not in tables:
op.create_table(
"core_recovery_operations",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("installation_id", sa.String(length=100), nullable=False),
sa.Column("module_id", sa.String(length=100), nullable=False),
sa.Column("operation_type", sa.String(length=100), nullable=False),
sa.Column("resource_type", sa.String(length=100), nullable=True),
sa.Column("resource_id", sa.String(length=255), nullable=True),
sa.Column("mode", sa.String(length=40), nullable=False),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("idempotency_key", sa.String(length=200), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("plan", sa.JSON(), nullable=False),
sa.Column("backup_reference", sa.String(length=1000), nullable=True),
sa.Column("approval_reference", sa.String(length=1000), nullable=True),
sa.Column("lease_resource_key", sa.String(length=255), nullable=True),
sa.Column("holder_node_id", sa.String(length=200), nullable=True),
sa.Column("holder_incarnation", sa.String(length=36), nullable=True),
sa.Column("fencing_token", sa.BigInteger(), nullable=True),
sa.Column("checkpoint_count", sa.Integer(), nullable=False),
sa.Column("evidence_head_sha256", sa.String(length=64), nullable=True),
sa.Column("failure_summary", sa.Text(), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("recovery_started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("recovered_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_recovery_operations")),
sa.UniqueConstraint(
"installation_id",
"module_id",
"idempotency_key",
name="uq_core_recovery_operation_idempotency",
),
)
for column in (
"installation_id",
"module_id",
"operation_type",
"resource_type",
"resource_id",
"mode",
"status",
):
op.create_index(
op.f(f"ix_core_recovery_operations_{column}"),
"core_recovery_operations",
[column],
unique=False,
)
op.create_index(
"ix_core_recovery_operations_status_updated",
"core_recovery_operations",
["installation_id", "status", "updated_at"],
unique=False,
)
op.create_index(
"ix_core_recovery_operations_resource",
"core_recovery_operations",
["module_id", "resource_type", "resource_id"],
unique=False,
)
if "core_recovery_checkpoints" not in tables:
op.create_table(
"core_recovery_checkpoints",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("operation_id", sa.String(length=36), nullable=False),
sa.Column("sequence", sa.Integer(), nullable=False),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("kind", sa.String(length=80), nullable=False),
sa.Column("summary", sa.Text(), nullable=False),
sa.Column("evidence", sa.JSON(), nullable=False),
sa.Column("previous_sha256", sa.String(length=64), nullable=True),
sa.Column("checkpoint_sha256", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["operation_id"],
["core_recovery_operations.id"],
name=op.f(
"fk_core_recovery_checkpoints_operation_id_core_recovery_operations"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_recovery_checkpoints")),
sa.UniqueConstraint(
"operation_id",
"sequence",
name="uq_core_recovery_checkpoint_sequence",
),
)
for column in ("operation_id", "status", "checkpoint_sha256"):
op.create_index(
op.f(f"ix_core_recovery_checkpoints_{column}"),
"core_recovery_checkpoints",
[column],
unique=False,
)
op.create_index(
"ix_core_recovery_checkpoints_operation_created",
"core_recovery_checkpoints",
["operation_id", "created_at"],
unique=False,
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
for table in (
"core_recovery_checkpoints",
"core_recovery_operations",
"core_distributed_leases",
"core_runtime_nodes",
):
if table in tables:
op.drop_table(table)
+27 -2
View File
@@ -5,7 +5,7 @@ only linear screen flows. Workflows, schedules, imports, connectors, policies,
and external events all need to request governed actions without bypassing the
same safety rules that apply to human users.
The first implementation should live in `govoplan-workflow` and core contracts.
The first implementation lives in `govoplan-workflow-engine` and Core contracts.
Create a separate `govoplan-automation` module only if action planning,
schedulers, rule execution, or cross-module automation become too broad for
workflow ownership.
@@ -29,6 +29,10 @@ of module capabilities.
## Action Definition
An `ActionDefinition` describes something a human or system actor can request.
The versioned runtime DTOs and provider protocol live in
`govoplan_core.core.automation`; domain modules implement the protocol and
Workflow resolves providers through module capabilities rather than importing
their implementations.
Recommended fields:
@@ -95,6 +99,22 @@ The runner must never advance workflow state past a required side effect unless
the action definition explicitly allows asynchronous completion and the pending
state is visible.
For external and asynchronous effects, providers must preserve the distinction
between:
1. requested intent;
2. approved intent;
3. dispatched command;
4. possibly executed but unconfirmed outcome;
5. confirmed observed effect;
6. reconciled, corrected, or compensated outcome.
An API timeout after dispatch is not a failed effect and must not be retried as
a fresh command. The actor context should retain the real identity/account,
represented function or party, delegation or power, and mandate/jurisdiction
references when applicable. Domain modules remain responsible for deciding
which of those references are required for their action.
## Failure States
Automation should use explicit failure states:
@@ -110,10 +130,15 @@ Automation should use explicit failure states:
These states should be visible in workflow, task, and admin diagnostics.
The contract names these states explicitly as `ActionExecutionState`, alongside
`pending`, `running`, and `completed`. A provider returns observed effects even
for partial failures; the runner, not the provider, owns durable attempts,
recovery decisions, and workflow advancement.
## Boundary
Core may own stable DTOs, registry contracts, and generic audit/event hooks.
`govoplan-workflow` should own the first runner because workflow is the first
`govoplan-workflow-engine` owns the first runner because workflow is the first
module that coordinates cross-module process actions.
Domain modules own their own action providers. For example, templates own
+59
View File
@@ -0,0 +1,59 @@
# Automation Contracts
Core defines provider-neutral automation contracts. It does not own domain
schedules, Workflow graphs, or Dataflow execution.
## Invocation Envelope
`AutomationInvocation` classifies a start as `manual`, `api`, `schedule`,
`event`, `workflow`, `dependency`, `retry`, or `backfill`. It carries opaque
trigger and delivery references, event identity, correlation and causation
IDs, scheduled time, requesting actor, and bounded metadata. Domain runs store
this envelope with their immutable definition revision.
## Current Authorization
An automated trigger must not persist a user session, bearer token, API key,
or a snapshot of all current permissions. It stores:
- tenant, account, and membership IDs;
- an opaque authorization reference;
- the minimum scopes required by the pinned definition and output target.
At delivery time the optional
`auth.automationPrincipalProvider` capability resolves current account,
membership, role, group, function, and delegation state. It intersects current
authorization with the stored grant. Missing, inactive, or reduced
authorization blocks the delivery before effects occur.
## Definition Governance
The optional `policy.definitionGovernance` capability evaluates `view`,
`edit`, `run`, `reuse`, `derive`, and `automate` for system, tenant, group, and
user definitions. A decision contains an ordered source path and effective
limits. Derived definitions pin their source revision and hash and retain
ancestor ceilings. Templates are reusable definitions and cannot run on their
own.
Without Policy, domain modules use a conservative tenant-local fallback:
local definitions remain viewable/editable and active complete flows may run;
inheritance, reuse, derivation, and automation are unavailable.
## Delivery Durability
Domain trigger implementations persist idempotent deliveries before running.
`emit_platform_event` binds event delivery to the producer's SQLAlchemy
transaction. When an enabled module provides `platform.eventOutbox`, the event
is stored in that transaction and a dispatcher may retry it across restarts and
workers. The Audit module provides the current SQL outbox implementation; the
Celery `govoplan.events.dispatch_outbox` task drains it through the Dataflow
event-ingestion capability and the local event bus.
The outbox capability remains optional so reduced module combinations can
start. Without it, Core queues events on the SQLAlchemy transaction and
publishes them to the process-local bus only after the outer commit. A rollback,
including a nested savepoint rollback, discards the corresponding events. This
fallback is suitable for local or non-critical reactions, but it is not a
durable multi-worker automation source. Deployments that rely on event-triggered
work must enable the outbox provider and run the `events` worker queue and
periodic dispatcher.
+10
View File
@@ -49,6 +49,15 @@ The broad writable root reduces approval churn. The explicit project trust entri
Each active repository has an `AGENTS.md` file. These files define ownership, module boundaries, and focused commands for Codex. Keep durable project conventions there instead of repeating them in every prompt.
Documentation is part of the completion criteria for every behavior change. The
owning module must update its manifest-driven `DocumentationTopic` contributions
for affected user and administrator workflows, settings, permissions,
limitations, and operational consequences. Feature documentation remains in the
feature module; the optional `govoplan-docs` module projects those contributions
without importing feature internals. Every module manifest must retain a static
user and administrator baseline even when runtime providers add configured-state
details.
Use `~/.codex/config.toml` for personal defaults, auth/runtime settings, writable roots, and trust decisions. Avoid checking in absolute-path writable roots or model preferences unless they are intentionally team-wide.
Use Gitea issues as the canonical backlog and state log. See `docs/GITEA_ISSUES.md` for label setup, TODO import, and Codex issue update commands. Durable docs should describe stable behavior; changing work state belongs on the issue.
@@ -81,5 +90,6 @@ PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versi
- Avoid broad recursive scans and full builds unless the change warrants them.
- Keep generated build/test folders ignored.
- Keep optional module behavior behind core registry/capability/module metadata boundaries.
- Run `tools/checks/check-manifest-shapes.py` after module behavior or manifest changes so user/admin documentation coverage remains complete.
- Create or update Gitea issues for TODOs, follow-ups, blockers, and feature requests instead of keeping local backlog files.
- Do not start persistent dev servers unless the user asks.
+43
View File
@@ -0,0 +1,43 @@
# GovOPlaN Compatibility Inventory
This inventory classifies compatibility paths covered by
`COMPATIBILITY_POLICY.md`. It is intentionally limited to behavior that changes
accepted data, imports, permissions, or migration state. Operational fallbacks
such as Redis degradation and language fallback are not compatibility paths.
## Database Bridges
| Path | Purpose | Retention | Removal |
| --- | --- | --- | --- |
| `govoplan_core.db.migrations.reconcile_legacy_create_all_schema` | Reconciles databases created before Alembic ownership was recorded. | At least one major release after runtime aliases are removed. | Review after `1.0`; keep release-baseline tests. |
| Migration table/column aliases in `govoplan_core.db.migrations` | Detect and reconcile pre-split table ownership and migration tracks. | All tagged `0.1.x` upgrade origins plus one major release cycle. | Remove only after the corresponding baseline leaves support. |
| Access and module migration backfills for legacy permission names | Converts persisted role assignments without dropping authority. | Same as the database upgrade origin that contains the old role. | Keep migrations immutable; remove only runtime expansion at `0.2`. |
## Portable-Schema Readers
| Path | Purpose | Retention | Removal |
| --- | --- | --- | --- |
| `govoplan_core.mail.config.normalize_split_transport_credentials` | Reads pre-split SMTP/IMAP credentials and emits the split representation. | Current and previous two configuration schema versions. | Version-gate once Mail writes an explicit current schema version; reject inputs older than the two-version window. |
| `ImapServerConfig.discard_legacy_enabled` | Reads the former nested IMAP `enabled` field without writing it. | Current and previous two configuration schema versions. | Remove with the oldest accepted Mail configuration schema. |
| `govoplan_core.core.configuration_packages` readers | Reads explicitly versioned configuration-package manifests. | Current and previous two schema versions. | Retire individual readers as their version leaves the window. |
## Runtime And API Aliases
| Path | Purpose | Retention | Removal |
| --- | --- | --- | --- |
| `govoplan_core.security.scope_aliases.LEGACY_SCOPE_ALIASES` | Expands pre-granular permission names. | Tagged `0.1.x` runtime/API window. | Remove at `0.2` after role backfills and migration notes are verified. |
| `govoplan_core.security.module_permissions.LEGACY_TO_MODULE_SCOPES` | Maps pre-module-split scopes to canonical owning-module scopes. | Tagged `0.1.x` runtime/API window. | Remove at `0.2`; keep database migration evidence for one major cycle. |
| `govoplan_core.privacy.retention` | Stable import facade delegating policy-owned behavior through a capability. | Tagged `0.1.x` import window. | Remove at `0.2` after all in-tree callers use the policy contract and release notes name the replacement. |
| Legacy tenant aliases in API response schemas | Preserves active-tenant response fields used by `0.1.x` clients. | Tagged `0.1.x` API window. | Remove at `0.2` with response-schema migration notes. |
| Optional fields in Poll response references | Accepts providers built against the earlier Poll contract. | Tagged `0.1.x` runtime contract window. | Remove or require a new interface version at `0.2`. |
| Legacy single-tenant summary providers | Allows modules without the batch provider introduced in `0.1.x`. | Tagged `0.1.x` module contract window. | Remove at `0.2` after manifests advertise the batch provider contract. |
## Removed Paths
| Path | Reason | Removed |
| --- | --- | --- |
| `govoplan_core.core.module_installer._run_restart_command_legacy` | Private wrapper had no callers and never represented a persisted or published contract. | Current development line |
| Retired `govoplan_core.api.admin` and pre-split core model imports | In-tree callers and module packages use their owning modules; regression tests prohibit reintroduction. | Before `0.1.10` |
Every new compatibility path must be added here with its classification,
diagnostic, test owner, and planned removal release.
+69
View File
@@ -0,0 +1,69 @@
# GovOPlaN Compatibility Policy
This document defines the compatibility window that release tooling, module
owners, migration authors, import/export providers, and API maintainers must
preserve. It is the source of truth for deciding whether compatibility code can
be removed.
## Database Upgrades
- A released installation from every tagged `0.1.x` version is a supported
database upgrade origin.
- The recorded public release-baseline ledger starts at `v0.1.7`; earlier
`0.1.x` tags predate production installations. If an earlier tagged database
is encountered, the release must provide or document a compatibility bridge
instead of silently treating the database as a fresh installation.
- Released migration revision IDs and recorded release heads are immutable.
- Each release must prove an upgrade from every still-supported recorded
baseline, as well as a fresh installation, before its tag is published.
- Migration-only reconciliation needed by an old database remains available for
at least one subsequent major release cycle after the corresponding runtime
compatibility path is removed.
The release-baseline format and commands are documented in
`RELEASE_DEPENDENCIES.md`.
## Configuration And Export Schemas
- Writers emit only the current schema version.
- Readers accept the current schema version and the previous two schema
versions.
- Older input is rejected with a diagnostic that identifies its version and the
required staged upgrade or conversion path.
- A module-owned configuration provider must version its input and output
schema explicitly. It must not infer an old schema from missing fields once a
versioned schema has shipped.
- Round-trip and upgrade tests must cover all three readable versions before a
schema change is released.
This window applies to configuration packages, module-owned exports, and other
portable GovOPlaN configuration artifacts. Domain interchange standards with
their own compatibility rules remain governed by the owning module.
## Runtime And API Aliases
- Compatibility aliases must emit an explicit deprecation diagnostic and point
to the supported replacement.
- New callers must use the canonical contract. In-tree callers may not add new
uses of a deprecated alias.
- Runtime imports, request fields, response fields, routes, and scope aliases
carried for the `0.1.x` split line are retired at `0.2`, with migration notes.
- An alias may be removed earlier only when it never shipped in a tag or when a
security fix requires removal. The release notes must state the exception.
- Database reconciliation code is not a runtime/API alias and follows the
longer database window above.
## Removal Checklist
Compatibility code can be removed only when all of the following are true:
1. The path is inventoried as a database bridge, portable-schema reader, or
runtime/API alias.
2. Its minimum retention window has elapsed.
3. In-tree callers and published module manifests use the replacement.
4. Upgrade, import, or API regression tests cover the retained window.
5. Diagnostics and migration notes identify any staged action operators must
take.
If one condition is not met, version-gate the compatibility path and record its
planned removal release instead of deleting it.
+37
View File
@@ -48,6 +48,35 @@ interface = how configured parts connect
data = what the operator must provide for this deployment
```
## Package Classes
The same signed package mechanism supports several explicitly named classes:
| Class | Purpose |
| --- | --- |
| `reference` | Prove a bounded journey, degraded states, recovery, and target-environment acceptance. |
| `product` | Provide a reusable operating capability such as governed communication, service-to-decision, procurement/contracts, or governed BI. |
| `sector` | Specialize terminology, forms, rules, process baselines, controls, reports, and integrations for an institutional sector without forking modules. |
| `deployment` | Declare a supported infrastructure topology, operational assumptions, health gates, and recovery evidence. |
| `integration` | Declare external systems, provider bindings, source-authority modes, maturity, required health, and reconciliation behavior. |
Package class is metadata and validation context, not additional authority. A
sector package does not become a module and cannot write another module's
tables. Packages may extend other packages only through versioned fragments and
must preserve provenance and parent constraints.
The contract enforces class-specific evidence. Reference packages require
target, recovery, security, operations, accessibility, privacy, and
documentation evidence. Deployment and integration packages require their
corresponding target/recovery/operations evidence, while integration packages
also name provider authority and minimum-maturity expectations. Preflight
blocks a missing, incompatible, or unhealthy provider. A derived package may
tighten parent module, capability, and provider requirements but cannot remove
or loosen them. Every non-documentation claim made by reference, deployment, or
integration packages carries a `sha256:<digest>` binding. Repository checks
recompute those hashes, while signed package verification protects the declared
manifest during transport.
## Package Model
A configuration package should be a signed, portable manifest plus module-owned
@@ -68,6 +97,14 @@ Required package metadata:
- preflight checks and post-import health checks
- migration or transformation rules for older package versions
- provenance, export source metadata, and signature metadata
- package class and optional parent package/version constraints
- source-authority bindings and provider-operation expectations for every
external integration used by the package
Portable configuration schemas follow `COMPATIBILITY_POLICY.md`: providers
write only their current schema version and read that version plus the previous
two versions. Older input must produce a version-specific staged-upgrade
diagnostic.
Configuration fragments are interpreted only by the module that owns them. For
example, workflow imports workflow definitions; forms imports form schemas;
+67
View File
@@ -0,0 +1,67 @@
# DataGrid Sizing Contract
`DataGrid` turns every declared track into a deterministic pixel layout after
its container has a measurable width. The same contract is used on initial
layout, container resize, persisted-layout restore, and pointer resize.
## Column Declarations
- `width: number` or `Npx` is the preferred pixel width.
- `width: N%` is a preferred share of the measured container.
- `width: Nfr` shares residual width by fraction weight.
- `width: minmax(Npx, preferred)` combines a hard lower bound with any
supported preferred width.
- An omitted width and the legacy `fill` flag are one-fraction flexible tracks.
- `minWidth` is a hard floor. Header sort, filter, and resize controls may raise
the effective accessible floor.
- `maxWidth` bounds direct user growth and free/constrained compensation. In a
cover layout it is a preferred maximum: passive tracks may exceed it when
that is necessary to keep the table flush with its container.
## Layout Modes
| `initialFit` | `resizeBehavior` | Initial layout | Pointer resize |
| --- | --- | --- | --- |
| `container` | `cover` | Real columns fill the container. Hard-minimum excess scrolls horizontally. | Growth may create horizontal overflow. Shrink first consumes overflow, then grows resizable columns to the right; it stops before underflow. |
| `content` | `cover` | After measurement, the same cover invariant applies. | Same as cover above. |
| `container` | `constrained` | Real columns fill the container. | Peer compensation respects every hard min/max and stops the active resize when capacity is exhausted. |
| `content` | `free` | Declared content widths are retained. | Only the active column changes, so underflow or horizontal overflow is allowed. |
| `container` | `free` | The initial layout fills the container. | Later user resizing is free. A right-sticky column promotes this mode to cover so its edge remains stable. |
Sticky columns do not absorb ordinary cover residuals and are not resize
compensation targets. A last resizable column may grow into overflow. It may
shrink only by the current overflow, because shrinking farther would require a
blank filler track. Dragging farther past that stop does not bank width changes:
the column remains stopped until the pointer crosses the same boundary again.
## Persistence
Only the pixel layout resulting from an explicit user resize is persisted.
Persisted widths are keyed by a signature containing column IDs, declared
widths and bounds, resize affordances, sticky placement, initial fit, and resize
behavior. A changed signature discards the old override and recomputes the
declared layout.
Container reconciliation is suspended while a pointer drag is active. On
release, the already-rendered pixel layout becomes the persisted preference.
Reconciliation may grow it to prevent underflow, but never shrinks intentional
user overflow, so there is no drag-end snap.
## Regression Matrix
`webui/tests/data-grid-sizing.test.ts` covers:
- pixel, percentage, fraction, `minmax`, omitted, and legacy-fill tracks;
- preferred max exhaustion without a synthetic filler column;
- hard-minimum horizontal overflow;
- fixed-only cover grids;
- persisted overrides under growth and viewport pressure;
- stale layout signatures;
- first and middle-column right-side compensation;
- last-resizable-column overflow, underflow stop, and reverse-pointer boundary;
- free, cover, and constrained resizing;
- cover-expanded tracks that already exceed preferred maxima; and
- preservation of the pointer layout across the commit fit.
`webui/tests/data-grid-actions.test.tsx` also verifies the rendered fixed-cover
shape and guards against reintroducing a synthetic buffer cell.
+68 -6
View File
@@ -144,8 +144,12 @@ tools/checks/postgres-integration-check.py \
```
The integration check runs migrations and startup smoke checks across the
standard module permutations. `--reset-schema` is destructive and belongs only
on throwaway databases.
standard module permutations. It first requires the retirement atomicity proof,
using Files' real secret-owning provider and Audit's persistent recorder. That
proof uses only random, test-owned schemas and cleans them afterward; it does
not reset `public`. `--reset-schema` is destructive and belongs only on
throwaway databases. Do not pass `--skip-retirement-atomicity` when collecting
release evidence.
### Broker And Workers
@@ -153,13 +157,15 @@ on throwaway databases.
| --- | --- | --- |
| `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. |
| `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. |
| `CELERY_QUEUES` | `send_email,append_sent,notifications,calendar,default` | Queue list expected by worker/process manager definitions. The Calendar queue drains durable external-calendar operations. |
| `CELERY_QUEUES` | `send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default` | Queue list expected by worker/process manager definitions. Keep this aligned with every enabled module task route; Ops reports missing worker queue consumers. |
| `PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS` | `8` | Failed durable consumer deliveries are quarantined after this many attempts. |
| `PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS` | `90` | Successful event envelopes older than this are removed by the daily retention task. Quarantined evidence is retained. |
Worker command:
```bash
python -m celery -A govoplan_core.celery_app:celery worker \
--queues send_email,append_sent,notifications,calendar,default \
--queues send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default \
--loglevel INFO
```
@@ -183,6 +189,11 @@ python -m celery -A govoplan_core.celery_app:celery beat --loglevel INFO
| `FILE_STORAGE_S3_ACCESS_KEY_ID` | empty | Secret; inject through deployment environment. |
| `FILE_STORAGE_S3_SECRET_ACCESS_KEY` | empty | Secret; inject through deployment environment. |
| `FILE_STORAGE_S3_BUCKET` | `files` | Managed-file object bucket. |
| `FILE_STORAGE_S3_DEPLOYMENT_MANAGED` | `false` | Reserved for the installer-owned `http://garage:3900` service. It does not authorize arbitrary internal or external S3 endpoints. |
| `FILE_ARCHIVE_MAX_ENTRIES` | `10000` | Maximum declared entries accepted during archive preview and confirmation. |
| `FILE_ARCHIVE_MAX_EXPANDED_BYTES` | `2147483648` | Maximum actual expanded bytes across selected archive content. |
| `FILE_ARCHIVE_MAX_EXPANSION_RATIO` | `100` | Maximum expanded-to-compressed archive ratio. |
| `FILE_ARCHIVE_PREVIEW_TTL_SECONDS` | `1800` | Lifetime of the sealed actor/archive/destination-bound preview token. |
Legacy `S3_*` settings remain for older storage paths but new deployments should
prefer `FILE_STORAGE_*`.
@@ -192,15 +203,64 @@ prefer `FILE_STORAGE_*`.
| Setting | Default | Notes |
| --- | --- | --- |
| `CORS_ORIGINS` | local dev origins | Set to the exact WebUI origins in staging/production. |
| `GOVOPLAN_TRUSTED_HOSTS` | empty | Exact API host names accepted by the application. Production-like validation requires an explicit list; narrowly scoped `*.example.org` entries are supported. |
| `FORWARDED_ALLOW_IPS` | Uvicorn default | Address or network of the trusted reverse proxy. Never use `*` in production-like deployments. |
| `AUTH_SESSION_COOKIE_NAME` | configured default | Change only through a controlled rollout because it logs users out. |
| `AUTH_CSRF_COOKIE_NAME` | configured default | Must match WebUI/API deployment. |
| `AUTH_COOKIE_SECURE` | `false` | Set `true` behind HTTPS. |
| `AUTH_COOKIE_SAMESITE` | `lax` | Use a stricter value only after testing login and CSRF flows. |
| `AUTH_COOKIE_DOMAIN` | empty | Set only when the API and WebUI intentionally share a parent domain. |
| `GOVOPLAN_HTTP_HSTS_SECONDS` | `31536000` in production, otherwise `0` | Emitted only for HTTPS requests. Set `0` while rehearsing a deployment that is not yet HTTPS-only. |
| `GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES` | `536870912` (512 MiB) | Deployment hard ceiling; file and module APIs apply their own lower limits where appropriate. |
Interactive password login is enabled with fixed-window limits of 10 failures
per normalized identity and 100 failures per direct client over 900 seconds.
`AUTH_LOGIN_THROTTLE_*` settings change those limits. Counters use `REDIS_URL`
when Redis is reachable so replicas share state. Production-like startup fails
when throttling is enabled without `REDIS_URL`. Set
`GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE=true` only as an explicit
single-process risk acceptance. A bounded process-local fallback keeps
development and temporary Redis outages functional, with per-process
enforcement until Redis recovers; monitor Redis because protection is weaker
during that fallback.
### Outbound Connector Egress
| Setting | Default | Notes |
| --- | --- | --- |
| `GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS` | `true` in dev/test, otherwise `false` | Deployment-wide decision. Set `true` only when pinned HTTP(S), DAV, SMTP, or IMAP transports must reach internal addresses. It does not enable an SDK transport that cannot pin every peer. |
| `GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES` | `16777216` (16 MiB) | Maximum buffered JSON, XML, iCalendar, vCard, catalog, and connector error response. |
| `GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES` | `536870912` (512 MiB) | Hard upper bound for a single remote file; module upload limits may be lower. |
| `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST` | empty | Comma-separated exact environment names usable by deployment-owned connector profiles. Tenant/API-managed profiles cannot select process variables, even when a name is listed. |
| `GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST` | empty | Comma-separated exact absolute CA bundle paths. Mount the same files at the same paths on every API and connector worker. |
Production-like configuration validation requires the private-network choice to
be explicit. HTTP connector downloads are streamed up to the configured bound,
and credential-bearing DAV redirects remain confined to their configured
origin.
The urllib, HTTPX/httpcore, SMTP, and IMAP transports resolve, validate, and
connect to the same approved address record while retaining the original host
for HTTP Host, TLS SNI, and certificate verification. Live SMB and S3 access
fails closed in both public-only and private-network deployments: the current
SDK transports cannot pin every initial and secondary peer or revalidate every
SDK-managed redirect/referral. An explicit IP endpoint does not bypass this
rule. Production deployments should still enforce the same decision at their
worker/container egress firewall or outbound proxy as a second boundary.
File connector TLS verification may be disabled only in dev/test. A custom CA
bundle must be an existing regular file whose resolved absolute path is listed
in `GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST`. Environment-backed file connector
credentials are supported only in deployment-owned connector JSON and require
their exact names in `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST`; UI/API profiles
must use encrypted stored credentials or a scoped secret-provider reference.
Public URLs are currently supplied by deployment/reverse-proxy configuration and
module settings. Do not hardcode them in core; configuration packages should ask
for portal, WebUI, postbox, and notification URLs when they become relevant.
Uvicorn applies `X-Forwarded-*` only from `FORWARDED_ALLOW_IPS`; keep that value
aligned with the reverse proxy and do not expose the application server directly
through the same trusted address range.
### Module Catalogs, Licenses, And Trust Roots
@@ -283,7 +343,7 @@ the checked in `.env.example`. It runs:
- explicit `ENABLED_MODULES`
- explicit migrations and `--with-dev-data` bootstrap
- API via the module-aware devserver
- a Celery worker for `send_email,append_sent,notifications,calendar,default`
- a Celery worker for `send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default`
- WebUI through the Vite dev server
- durable local files under `runtime/production-like/files`
@@ -402,7 +462,9 @@ Run the rollback drill before relying on installer automation in a new
environment:
```bash
/mnt/DATA/git/govoplan/tools/checks/module-installer-rollback-drill.py --format json
/mnt/DATA/git/govoplan/tools/checks/module-installer-rollback-drill.py \
--format json \
--evidence-path runtime/module-installer/restore-drill-evidence.json
```
The drill uses temporary SQLite databases and simulated package commands. It
+6
View File
@@ -9,12 +9,16 @@ operator, and roadmap pages.
| Topic | Canonical document | Notes |
| --- | --- | --- |
| Module architecture and kernel contracts | `MODULE_ARCHITECTURE.md` | Stable module contracts, API efficiency contracts, durable boundary decisions, lifecycle, and WebUI contribution rules. |
| Compatibility policy | `COMPATIBILITY_POLICY.md` | Supported database upgrade origins, portable-schema read/write windows, runtime/API alias retirement, and compatibility-code removal criteria. |
| RBAC and resource access | `ACCESS_RBAC_MODEL.md` | Current permission, role, API-key, and resource-access model. |
| Governance hierarchy | `GOVERNANCE_MODEL.md` | System, tenant, user/group, campaign policy inheritance and admin UI structure. |
| Policy decision DTOs and provenance | `POLICY_CONTRACTS.md` | Shared explain/provenance shape; module-specific policy docs should link here. |
| Events and audit trace context | `EVENTS_AND_AUDIT.md` | Event dispatch semantics and audit payload conventions. |
| Action/effect automation layer | `ACTION_EFFECT_AUTOMATION_LAYER.md` | Action/effect contracts, consequence preview, runner semantics, and module boundary for automation. |
| External references and integration maturity | `EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md` | Stable external identity and cumulative connector maturity; configured source authority is defined by the meta target architecture. |
| Institutional context and governed references | `INSTITUTIONAL_CONTEXT_CONTRACT.md` | Shared temporal, actor/representation, institution, mandate, service, party, decision, evidence, legal-basis, information-governance, presentation, and geo DTO/provider contracts. |
| Postbox E2EE target architecture | `POSTBOX_E2EE_ARCHITECTURE.md` | Strategic encrypted postbox/mailbox model, key ownership, role mailbox semantics, and retraction limits. |
| Shared state, runtime coordination, and recovery | `STATE_AND_RECOVERY_CONTRACT.md` | State profiles, object storage, node registration/drain, fenced leases, migration ordering, and recovery evidence. |
## Release And Operations
@@ -25,12 +29,14 @@ operator, and roadmap pages.
| Release dependencies and catalogs | `RELEASE_DEPENDENCIES.md` | Release package refs, migration baselines, release lockfiles, catalog trust/licensing, catalog publishing, and release checklist. |
| Dependency vulnerability audits | `DEPENDENCY_AUDITS.md` | Local and CI audit commands plus dated audit result notes. |
| Remote WebUI bundle design | `REMOTE_WEBUI_BUNDLES.md` | Experimental controlled-deployment design; normal releases still use package builds. |
| WebUI loading and bundle budgets | `WEBUI_BUNDLE_BUDGETS.md` | Installed-module lazy boundaries, enforced initial/async budgets, and baseline measurements. |
## Product And Module Planning
| Topic | Canonical document | Notes |
| --- | --- | --- |
| Product roadmap and module routing | `GOVOPLAN_MASTER_ROADMAP.md` | Product-level sequencing, implementation gates, issue routing, and missing-module decisions. |
| Institutional governance target | `govoplan/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md` | Cross-product semantic layers, source-authority modes, candidate Mandates/Services/Parties/Decisions boundaries, and migration sequence. |
| UI/UX decisions | `UI_UX_DECISION_LEDGER.md` | Binding guided-UI decisions, open decisions, impact index, and review checklist. |
| Interface ethics and design doctrine | `INTERFACE_ETHICS_AND_DESIGN_DOCTRINE.md` | Product-level doctrine for context, decision, consequence, contestability, responsibility, and traceability. |
| Public-sector integration posture | `PUBLIC_SECTOR_INTEGRATION_STRATEGY.md` | Strategy index; executable target inventory lives in `govoplan-connectors`. |
+27 -19
View File
@@ -8,25 +8,32 @@ module reactions, and operator diagnostics.
## Production Transport Decision
The first production target is a **database outbox plus in-process immediate
dispatch**:
The production transport is a **transactional database outbox plus retrying
dispatcher**:
- Use `govoplan_core.core.events.PlatformEvent` for domain and platform events.
- Use `EventBus` as the in-process dispatch contract for same-process module
reactions that are safe to run inline.
- Call `emit_platform_event(session, event)` to bind event delivery to the
domain transaction.
- The optional `platform.eventOutbox` capability persists events atomically.
The Audit module provides the current SQL implementation.
- Without an outbox provider, Core publishes to `EventBus` only after the outer
transaction commits. This preserves reduced installations but is not durable
across process failure or multiple workers.
- Use `EventBus` as the in-process dispatch contract for module reactions
invoked by the outbox dispatcher or for non-critical fallback reactions.
- Use the shared `audit_event` / `audit_from_principal` helper for audited
module actions. The helper persists the audit row and immediately publishes a
module actions. The helper persists the audit row and transactionally emits a
governed `PlatformEvent` whose `type` is the audit action.
- Use `record_change` for module delta feeds. It persists the change-sequence
row and immediately publishes a generic module change event such as
row and transactionally emits a generic module change event such as
`mail.profile.updated`.
- Persist durable integration/workflow events through a database outbox before
acknowledging the state change that produced them.
- Drain the outbox through a small dispatcher process. The dispatcher may call
in-process handlers in the same deployment first, but its storage contract is
database-backed.
- Drain the outbox with the Celery `govoplan.events.dispatch_outbox` task on the
`events` queue. The periodic schedule also retries pending rows.
- The dispatcher invokes Dataflow event ingestion when that capability is
active, then publishes to the process-local bus.
- Treat Redis/Celery as worker/job infrastructure, not as the authoritative
first event transport. A Celery dispatcher can consume the outbox later.
first event transport. PostgreSQL remains authoritative until dispatch is
recorded.
- Keep the dispatch implementation pluggable behind the `PlatformEvent`
envelope so a future message broker can be added without changing event
producers.
@@ -44,17 +51,18 @@ Event producers should write their domain state and outbox event in the same
database transaction wherever possible. Handlers must be idempotent because the
outbox dispatcher can retry after a crash or timeout.
Recommended first outbox columns:
The current outbox stores:
- `event_id`, `event_type`, `module_id`
- `correlation_id`, `causation_id`
- `payload`, `occurred_at`
- `available_at`, `attempt_count`, `claimed_at`, `claim_token`
- `processed_at`, `last_error`
- `classification`, serialized event `payload`
- `status`, `attempts`, `next_attempt_at`
- `dispatched_at`, `last_error`, timestamps
Inline `EventBus` handlers are allowed only for non-critical local reactions.
Anything that must survive process failure, restart, package update, or worker
redeployment belongs in the outbox.
Handlers must be idempotent: a worker may complete an external effect and fail
before marking its outbox row dispatched. Anything that must survive process
failure, restart, package update, or worker redeployment requires the outbox
provider and dispatcher.
## Trace IDs
@@ -0,0 +1,67 @@
# External References And Integration Maturity
GovOPlaN integrations use a shared external-reference contract instead of
storing connector-specific URLs and identifiers in every module.
An external reference identifies an object by:
- external system instance
- object type
- stable external object ID
- optional connector configuration
- canonical HTTP(S) URL without embedded credentials
- optional source version, ETag, observation time, and non-secret metadata
The identity key is `system:object_type:object_id`. A GovOPlaN object may retain
multiple references, but one reference must never silently change its identity.
Moving or escalating work creates a new object and an explicit relationship; it
does not rewrite either object's history.
## Integration Maturity
Maturity is cumulative:
1. `discover`: identify configured external systems and their health.
2. `link`: retain and open stable external references.
3. `search`: include authorized external objects in GovOPlaN search.
4. `read`: display authoritative external content.
5. `publish`: create or update external content from GovOPlaN.
6. `synchronize`: reconcile changes in both directions with conflict handling.
7. `migrate`: perform a governed, verifiable transfer into GovOPlaN.
8. `replace`: provide the native operational capability without the external tool.
Connectors must declare and document the maturity they actually implement.
`synchronize` requires durable cursors, idempotency, provenance, conflict
handling, deletion semantics, and observable failures. A link-only connector
must not imply that GovOPlaN holds an authoritative copy.
## Source Authority Is A Separate Dimension
Integration maturity states what an adapter is capable of doing. It does not
decide which system owns truth for a configured object or field group. A
binding separately selects one of the source-authority modes defined by the
[institutional governance target architecture](../../govoplan/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md):
- `native_authoritative`
- `external_authoritative`
- `external_mirror`
- `governed_sync`
- `governance_overlay`
- `linked_reference`
A connector can therefore support `synchronize` while a tenant deliberately
uses it only as an external mirror. Conversely, a native GovOPlaN object may
retain link-only references to several external systems. Authority may be
narrowed by tenant, organization, service, object type, object, field group, or
process step and must be visible in provenance and configuration preflight.
## Domain Ownership
- Domain modules own native GovOPlaN objects and their authorization.
- Connectors own protocols, credentials, discovery, transport, and sync state.
- Search owns indexing and result aggregation, but source modules remain
responsible for authorization.
- Core owns only the stable DTOs and extension contracts.
The Python contract is
`govoplan_core.core.external_references.ExternalObjectReference`.
+222 -155
View File
@@ -10,12 +10,17 @@ gates. Issues are the active backlog; this document is durable architecture
planning context and should be mirrored to the Gitea wiki.
The meta repository's
[Connected Governance Platform Roadmap](https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
[Connected Governance Platform Roadmap](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
describes the corresponding cross-product stakeholder visions, configurable
service and operating configurations, connected outcome stories, and
capability horizons. Its product horizons are canonical; the waves below give
their technical dependency order. This Core roadmap remains the technical
sequencing and module-routing companion.
capability horizons. The selected five-stage delivery sequence and its gates
are in the meta repository's
[Reference Journey Program](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/REFERENCE_JOURNEY_PROGRAM.md).
The semantic target, source-authority modes, and reconciliation with the
implemented platform are in the meta repository's
[Institutional Governance Target Architecture](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
Those product documents are canonical; this Core roadmap remains their
technical sequencing and module-routing companion.
## Product Thesis
@@ -62,8 +67,9 @@ verify or reverse those effects.
`INTERFACE_ETHICS_AND_DESIGN_DOCTRINE.md`.
- Automation must use governed action/effect contracts, not hidden side
effects. The first automation layer is defined in
`ACTION_EFFECT_AUTOMATION_LAYER.md` and should start in `govoplan-workflow`
unless a separate automation module becomes justified.
`ACTION_EFFECT_AUTOMATION_LAYER.md`; its first runner lives in
`govoplan-workflow-engine`. Create a separate automation module only if the
scheduler/action runtime outgrows workflow coordination.
- Encrypted postboxes are a strategic target. Early postbox, access, and
identity-trust contracts should stay compatible with the E2EE architecture in
`POSTBOX_E2EE_ARCHITECTURE.md`.
@@ -121,7 +127,8 @@ pattern exists.
## Focus Rules
1. Build one reference journey per wave.
1. Build one selected reference journey stage at a time; a later capability
cluster is not an active program merely because it appears below.
2. Do not implement a module because the repository exists.
3. Do not add module-to-module imports for optional behavior.
4. Every new domain module must justify its own semantics beyond `cases`,
@@ -141,18 +148,28 @@ pattern exists.
| Structured forms and validation | `govoplan-forms` |
| Uploaded files and managed storage | `govoplan-files` |
| Case record and lifecycle | `govoplan-cases` |
| Workflow transitions and automation | `govoplan-workflow` |
| Action/effect catalogue and automation runner | first `govoplan-workflow`; possible future `govoplan-automation` if it outgrows workflow |
| Workflow runtime, transitions, and automation | `govoplan-workflow-engine` |
| Workflow definition editing | optional `govoplan-workflow` |
| Action/effect catalogue and automation runner | first `govoplan-workflow-engine`; possible future `govoplan-automation` only if it outgrows workflow |
| Internal work queues and tasks | `govoplan-tasks` |
| Appointment proposals and booking | `govoplan-appointments`, `govoplan-calendar` |
| Postbox, email, and notifications | `govoplan-postbox`, `govoplan-mail`, `govoplan-notifications` |
| Canonical subjects and account links | `govoplan-identity` |
| Organizational structures, units, and functions | `govoplan-organizations` |
| Identity-to-function assignments and directory synchronization | `govoplan-idm` |
| Identity trust, device keys, and encrypted postbox key contracts | `govoplan-identity-trust`, `govoplan-access`, `govoplan-postbox` |
| Service directory/catalog | `govoplan-portal` |
| Service directory presentation | `govoplan-portal` |
| Versioned institutional service definition | shared service contract first; candidate `govoplan-services` after reuse proof |
| Mandate, jurisdiction, and institutional authority | shared mandate contract first; candidate `govoplan-mandates` after reuse proof |
| Procedure-local parties and representation | shared party contract first; candidate `govoplan-parties` after reuse proof |
| Formal institutional decisions | shared decision contract first; candidate `govoplan-decisions` after reuse proof |
| Permit/document generation | `govoplan-templates`, `govoplan-dms` |
| Payment capture and accounting handoff | `govoplan-payments`, `govoplan-ledger` |
| Roles, permissions, tenants, policy, audit | `govoplan-access`, `govoplan-tenancy`, `govoplan-policy`, `govoplan-audit` |
| Authentication projection, roles, permissions, acting context | `govoplan-access` |
| Tenants, policy, and audit | `govoplan-tenancy`, `govoplan-policy`, `govoplan-audit` |
| External software integration | `govoplan-connectors` |
| Recurring extraction and transformation | possible future `govoplan-datasources`, possible future `govoplan-dataflow` |
| Governed data/register catalogue | `govoplan-datasources` |
| Recurring extraction and transformation | `govoplan-dataflow` over Datasources and Connectors contracts |
| Reports, BI, and management visibility | `govoplan-reporting` |
## Configuration And Safety Target
@@ -197,56 +214,85 @@ an editor applies a high-impact configuration change.
## Reference Journeys
The roadmap should be driven by three journeys.
The active sequence is selected. Workflow Engine and its optional editor are
now available foundations, but a reference journey does not depend on Workflow
unless its package explicitly composes and proves it.
### Journey 1: Permit To Payment
### Journey 1: Campaign Demonstration Composition
This is the primary public-administration journey.
Campaign is the first complete proof of modular composition. Campaign owns
intent, recipient snapshots, personalization, execution state, and delivery
evidence. Mail owns reusable profiles, credentials, protocol policy, and
provider execution; Campaign stores only a selected profile reference. Files
owns storage, connector profiles, file policy, and provenance.
1. A person applies for a permit through the public portal.
2. The applicant uploads required files and submits structured form data.
3. Submission creates a case, a workflow instance, and an internal task.
4. Completing the task creates a postbox message, a notification, and an email
notification with an appointment proposal.
5. The applicant accepts an appointment, which updates the calendar and the
workflow state.
6. During the appointment, the case is opened and the permit is generated from
a governed template.
7. The payment is processed and linked to the case and accounting handoff.
8. The permit, payment evidence, communication history, audit trail, retention
state, and records evidence remain available according to policy.
The technical gate is a pinned Campaign/Mail/Files composition with central
UI, adaptive user/admin/operator/integration documentation, target SMTP/IMAP
and file-provider evidence, and explicit test/send/resend/retry/reconciliation
semantics. Readers must not receive backend paths, worker claims, secrets, or
raw provider diagnostics.
This journey proves the platform can coordinate modules without core knowing
module internals.
### Journey 2: Function-Bound Postbox Delivery
### Journey 2: Training To Certificate
Postbox accepts delivery to an addressable postbox or a function in an
organizational unit. Organizations owns units and functions, Identity owns
subjects, IDM owns identity-to-function assignments and upstream sync, and
Access resolves current roles, delegation, acting context, and permission.
This is the best university-administration and internal-administration journey.
Campaign consumes a typed delivery-target capability without importing Postbox
or identity internals. Reassignment changes future access without moving the
message; vacancy or ambiguous acting context fails visibly; delivery, access,
and correction remain auditable.
1. Course or training offer is planned.
2. Room, trainer, resource, and capacity are booked.
3. Participants register or are assigned.
4. Attendance is tracked.
5. Certificate or participation confirmation is issued.
6. Evidence remains available through records, files, audit, and docs.
### Journey 3: Data-Backed Templates, Reports, And Deep Launch
This journey keeps `booking`, `resources`, `learning`, and `certificates`
focused instead of becoming broad ERP replacements.
An authenticated user follows an opaque, short-lived launch reference from HIS
or another specialist system. GovOPlaN re-authorizes the actor, resolves a
curated data context server-side, displays source/freshness/version, and renders
one reproducible document and report.
### Journey 3: Report To Resolution
Templates owns definition/version/schema/rendering, Reporting owns source
selection/parameters/execution/export, Files owns generated bytes, and
connectors own protocol access. URLs do not carry credentials, arbitrary SQL,
or trusted raw personal data. Retries are idempotent and generation evidence
connects source, snapshot/reference, transformation, definition, parameters,
output checksum, actor, and policy.
This is the internal operations and municipal issue-reporting journey.
### Journey 4: Governed University BI Path
1. A person reports an issue.
2. The issue is triaged into helpdesk, facilities, assets, or a case.
3. Work is assigned, tracked, and escalated.
4. Evidence, communication, and status updates are preserved.
5. Reports show workload, SLA, recurring problems, and completion.
Starting from the Journey 3 source contract, one bounded university dataset is
catalogued, staged by snapshot or watermark, validated, transformed through a
versioned lineage graph, and exposed as a policy-aware analytical data product.
The result must preserve official-key mappings, organizational and reporting
date semantics, quality findings, quarantine/replay, transparent calculation,
and reproducible promotion between development, test, and production.
This journey prevents `helpdesk`, `issue-reporting`, `facilities`, and `assets`
from becoming disconnected ticket silos.
Reporting consumes the product. Datasources owns the governed source and
materialization lifecycle; Dataflow owns typed transformation/run lineage;
Connectors owns external transport. The concrete path must now prove those
implemented boundaries and expose any missing contracts instead of recreating
them inside Reporting or a producing domain module.
## Roadmap Waves
### Journey 5: Collaborative Document Lifecycle
An uploaded or generated artifact becomes a DMS document. Files continues to
own bytes; DMS owns identity, versions, renditions, editing sessions, locks,
comments, review, approval, comparison, and recovery; a collaboration connector
owns provider-specific protocol behavior; Records owns later classification,
hold, archive, and disposal.
The gate requires no silent lost updates, short-lived and currently authorized
editing sessions, idempotent authenticated callbacks, visible uncertain saves,
immutable accepted renditions, and a Records-ready handoff with stable content
and provenance.
## Capability Dependency Waves
The waves below remain a dependency and ownership catalogue for the wider
product vision. They are not the active delivery order. The five selected
journeys above and the meta roadmap decide what is implemented now; other
clusters remain dormant until a selected journey consumes them or they are
explicitly reprioritized.
### Wave 0: Platform Spine
@@ -256,9 +302,13 @@ Refine:
- `govoplan-core`: module discovery, capabilities, events, migrations, release
catalog, configuration package runtime, WebUI shell.
- `govoplan-access`: identities, sessions, API keys, users, groups, roles,
memberships, function assignments, delegation, RBAC decisions.
- `govoplan-tenancy`: tenant and organizational-unit boundaries.
- `govoplan-identity`: canonical identities and account links.
- `govoplan-organizations`: organizational structures, units, and functions.
- `govoplan-idm`: identity-to-function assignments, directory synchronization,
preview, conflicts, and reconciliation.
- `govoplan-access`: sessions, API keys, users, groups, roles, memberships,
function-to-role projection, delegation, acting context, and RBAC decisions.
- `govoplan-tenancy`: tenant lifecycle and tenant boundaries.
- `govoplan-identity-trust`: initial trust contracts for device keys, public key
directory, assurance, and later encrypted postbox key access.
- `govoplan-policy`: policy sources, policy decisions, retention inputs.
@@ -299,8 +349,9 @@ Create or refine in this order:
access.
4. `govoplan-cases`: case record, status, assignments, deadlines, and case
evidence.
5. `govoplan-workflow`: state machine, transitions, commands, and module
handoff.
5. `govoplan-workflow-engine`: state machine, transitions, commands, module
handoff, and resumable execution; optional `govoplan-workflow` supplies the
editor.
6. `govoplan-tasks`: work queues, assignments, due dates, and follow-ups.
7. `govoplan-templates`: permit/decision document generation.
8. `govoplan-postbox`, `govoplan-mail`, `govoplan-notifications`: applicant and
@@ -375,7 +426,7 @@ Goal: cover internal support and public issue reporting.
Create or refine in this order:
1. `govoplan-issue-reporting`: public/internal reports, categories, intake,
1. `govoplan-tickets`: public/internal reports, requests, incidents, queues,
location, evidence, and triage.
2. `govoplan-helpdesk`: service desk tickets, queues, SLAs, assignments,
escalation, and resolution evidence.
@@ -489,31 +540,34 @@ Refine:
dashboard data.
- `govoplan-search`: permissioned cross-module discovery.
Create only when justified:
Refine the existing owners:
- `govoplan-datasources`: source catalog, connection profiles, schema discovery,
freshness, provenance.
- `govoplan-dataflow`: transformations, validation, lineage, scheduled runs,
publication outputs.
- `govoplan-projects`: only if OpenProject connectors and existing cases/tasks
cannot cover the required semantics.
- `govoplan-datasources`: governed data/register catalog, live/cached/static
sources, staging, immutable materializations, freshness, quality, legal and
organizational context, and provenance. Connector profiles and credentials
remain in Connectors.
- `govoplan-dataflow`: typed transformations, validation, lineage, manual,
scheduled and event-triggered runs, reusable definitions, and publication
outputs.
- `govoplan-projects`: native projects, portfolios, milestones, goals,
dependencies, capacity, outcomes, and external OpenProject references;
Connectors owns OpenProject transport and synchronization.
Reference journey: monthly data extraction, transformation, validation, approval,
publication, and reporting.
Recurring extraction/transformation should start as a configuration package
across connectors, files, workflow, reporting, and templates. The package should
register sources, declare schemas, define mapping/validation versions, schedule
runs, produce previewable diffs, write governed outputs, and preserve lineage,
hashes, operator actions, and audit evidence. Create `govoplan-datasources` or
`govoplan-dataflow` only after this work exposes repeated contracts that do not
belong to existing modules.
Recurring extraction/transformation should be delivered as a configuration
package across Connectors, Datasources, Dataflow, Workflow Engine, Reporting,
Files, and Templates. The package should register sources, declare schemas,
define mapping/validation versions, schedule runs, produce previewable diffs,
write governed outputs, and preserve lineage, hashes, operator actions, and
audit evidence.
Exit criteria:
- connector catalog exists before building many adapters
- dataflow is created only after recurring transformation becomes product
behavior
- datasource and dataflow ownership remains provider-neutral and is proved by
the recurring transformation package
- reporting consumes governed sources with provenance
## Implementation Gates
@@ -540,27 +594,39 @@ Before a module becomes release-included, it needs:
## Technical Dependency Order Summary
When the meta roadmap selects one of these product programs, use this order to
respect shared-spine dependencies. The list is not an active schedule or a
decision to start every program horizontally.
Use this active order while respecting the ownership and implementation gates
in the capability waves:
1. Stabilize the platform spine.
2. Deliver permit-to-payment MVP.
3. Build booking and resource operations.
4. Add learning and certificates.
5. Add issue reporting and helpdesk.
6. Add records, DMS, search, and transparency.
7. Add procurement, contracts, grants, and finance handoff.
8. Add committee and consultation workflows.
9. Expand integration, dataflow, reporting, and operations.
1. Keep the platform/release spine green and extend connector, identity,
external-effect, provenance, documentation, focused-view, recovery, and
version contracts only as the current journey requires.
2. Complete and package Campaign with Mail-owned profiles, Files, target
delivery/recovery, central UI, and adaptive documentation.
3. Implement function-bound Postbox delivery through
OrganizationsIdentityIDMAccess and consume it from Campaign through a
typed capability.
4. Implement one data-backed Templates/Reporting path and safe HIS-style deep
launch.
5. Extend that concrete source into one governed university analytical data
product and use it to harden the existing Datasources/Dataflow ownership,
quality, lineage, and promotion contracts.
6. Implement Files-backed DMS versions and one provider-neutral collaborative
editing lifecycle, then connect Records handoff.
7. Maintain already integrated Calendar/Scheduling/Poll and other foundations;
activate another capability cluster only when the current journey needs it
or the product roadmap explicitly reprioritizes it.
8. Extend Workflow Engine and the optional editor only through stable actions
and one demonstrated package at a time.
## Deliberate Deferrals
Defer these until a reference journey proves the need:
- full ERP replacement
- native project management beyond connector support
- broad BI/dataflow platform
- unsupported breadth in native project management before the Projects/OpenProject
boundary is proved in a reference journey
- unbounded Dataflow operators or execution engines without golden-flow,
quality, lineage, resource-limit, and recovery evidence
- every possible public-sector protocol adapter
- rich LMS behavior beyond training administration
- full qualified digital signing/trust services beyond the identity-trust and
@@ -578,73 +644,69 @@ repositories or to explicit missing-module decisions.
| Idea | Owner | Tracking |
| --- | --- | --- |
| Government operations backbone reference model | `govoplan-core` | `add-ideas/govoplan-core#213` |
| Permit-to-payment configuration package | `govoplan-core` plus participating modules | `add-ideas/govoplan-core#214` |
| Fully UI-managed configuration with safety controls | `govoplan-admin`, `govoplan-core`, `govoplan-policy`, `govoplan-access`, `govoplan-audit` | `add-ideas/govoplan-core#218` |
| Access as a module | `govoplan-access` | `add-ideas/govoplan-access#7` |
| Interface ethics and decision-consequence doctrine | `govoplan-core` plus all UI-owning modules | `add-ideas/govoplan-core#227` |
| Action/effect automation layer | first `govoplan-workflow`; possible future `govoplan-automation` | `add-ideas/govoplan-workflow#1` |
| E2EE role/function postbox architecture | `govoplan-postbox`, `govoplan-identity-trust`, `govoplan-access`, `govoplan-policy`, `govoplan-audit` | `add-ideas/govoplan-postbox#15`, `add-ideas/govoplan-identity-trust#1` |
| Identity, account, function, role, right semantic model | `govoplan-access` | `add-ideas/govoplan-access#9` |
| Role-based service directory/catalog | `govoplan-portal` | `add-ideas/govoplan-portal#1` |
| Unified inbox across tasks, postbox, notifications, and portal | `govoplan-core` coordination plus owning modules | `add-ideas/govoplan-tasks#1`, `add-ideas/govoplan-notifications#1` |
| OpenProject API / project management connector | `govoplan-connectors` | `add-ideas/govoplan-connectors#1` |
| Native project-management module decision | connector-first through `govoplan-connectors`; no native project module yet | `add-ideas/govoplan-core#196`, `add-ideas/govoplan-connectors#1` |
| Datasources for databases, CSV, files, APIs | no repository yet; start with connectors/files/reporting and create `govoplan-datasources` only after the first package proves shared source-catalog ownership | `add-ideas/govoplan-core#197` |
| Dataflow for pipelines, BI, publication | no repository yet; start with workflow/reporting/connectors and create `govoplan-dataflow` only after repeated pipeline/lineage contracts emerge | `add-ideas/govoplan-core#198` |
| Monthly datasource and transformation workflows | first as configuration package across connectors, files, workflow, reporting, and templates | `add-ideas/govoplan-core#216` |
| Templates for letters, emails, forms, reports | `govoplan-templates`, separate from reporting | `add-ideas/govoplan-core#190`, `add-ideas/govoplan-templates#1` |
| Reporting and BI | `govoplan-reporting`, separate from templates | `add-ideas/govoplan-core#190`, `add-ideas/govoplan-reporting#1` |
| File connectors: Nextcloud, Seafile, SMB, NFS | `govoplan-files` | `add-ideas/govoplan-files#15` |
| Public-sector software integration catalogue | `govoplan-connectors` with core strategy index | `add-ideas/govoplan-core#191`, `add-ideas/govoplan-connectors#2` |
| Public-sector integration landscape catalogue | `govoplan-connectors` with core tracking | `add-ideas/govoplan-core#215` |
| Cases module concept | `govoplan-cases` | `add-ideas/govoplan-core#174` |
| Workflow module concept | `govoplan-workflow` | `add-ideas/govoplan-core#175` |
| Connectors module concept | `govoplan-connectors` | `add-ideas/govoplan-core#176` |
| Adrema-style address and distribution-list management | `govoplan-addresses` | `add-ideas/govoplan-addresses#1` |
| Consume sources and become a governed source | `govoplan-connectors` plus possible future `govoplan-dataflow` | `add-ideas/govoplan-connectors#3`, `add-ideas/govoplan-core#198` |
| Governed connector configuration, dry-run, and simulation runtime | `govoplan-connectors` | `add-ideas/govoplan-connectors#6` |
| Terminfindung and meeting scheduling polls | `govoplan-scheduling`; calendar primitives remain in calendar | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-scheduling#1` |
| Terminplaner and calendar primitives | `govoplan-calendar` | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-calendar#1` |
| Terminbuchung appointment booking | `govoplan-appointments` | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-appointments#1` |
| Collaborative documents | `govoplan-dms` | `add-ideas/govoplan-dms#1` |
| Forms | `govoplan-forms` for definitions and `govoplan-forms-runtime` for submissions/runtime behavior | `add-ideas/govoplan-core#194`, `add-ideas/govoplan-forms#1` |
| RSS consume and emit | `govoplan-connectors` | `add-ideas/govoplan-connectors#4` |
| LDAP, Active Directory, OpenDesk identity | `govoplan-idm` | `add-ideas/govoplan-idm#1` |
| OpenDesk stack integration map | integration profile across IDM/access, mail/calendar, files/DMS, and connectors; not a monolithic module | `add-ideas/govoplan-core#195`, `add-ideas/govoplan-connectors#5` |
| Open-Xchange mail/groupware | `govoplan-mail` | `add-ideas/govoplan-mail#5` |
| Open-Xchange calendar | `govoplan-calendar` | `add-ideas/govoplan-calendar#2` |
| Scalability profiles and autoscaling readiness | `govoplan-ops`, `govoplan-core` | `add-ideas/govoplan-core#217` |
| Hardware sizing matrix and requirements calculator | `govoplan-ops`, `govoplan-core` | `add-ideas/govoplan-core#219` |
| Collaboration suite integration strategy | `govoplan-connectors`, `govoplan-dms`, `govoplan-workflow`, `govoplan-tasks`, `govoplan-appointments`, `govoplan-calendar` | `add-ideas/govoplan-core#220` |
| Install/runtime configuration contract | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#19` |
| Installer/deployment operator flow | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#26` |
| Production-like deployment documentation | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#28` |
| Government operations backbone reference model | `govoplan-core` | `GovOPlaN/govoplan-core#213` |
| Permit-to-payment configuration package | `govoplan-core` plus participating modules | `GovOPlaN/govoplan-core#214` |
| Fully UI-managed configuration with safety controls | `govoplan-admin`, `govoplan-core`, `govoplan-policy`, `govoplan-access`, `govoplan-audit` | `GovOPlaN/govoplan-core#218` |
| Access as a module | `govoplan-access` | `GovOPlaN/govoplan-access#7` |
| Interface ethics and decision-consequence doctrine | `govoplan-core` plus all UI-owning modules | `GovOPlaN/govoplan-core#227` |
| Action/effect automation layer | `govoplan-workflow-engine`; possible future `govoplan-automation` only after boundary proof | `GovOPlaN/govoplan-workflow#1` |
| E2EE role/function postbox architecture | `govoplan-postbox`, `govoplan-identity-trust`, `govoplan-access`, `govoplan-policy`, `govoplan-audit` | `GovOPlaN/govoplan-postbox#15`, `GovOPlaN/govoplan-identity-trust#1` |
| Identity, account, function, role, right semantic model | `govoplan-access` | `GovOPlaN/govoplan-access#9` |
| Role-based service directory presentation | `govoplan-portal`; reusable service definition is a shared contract and candidate `govoplan-services` | `GovOPlaN/govoplan-portal#1` |
| Unified inbox across tasks, postbox, notifications, and portal | `govoplan-core` coordination plus owning modules | `GovOPlaN/govoplan-tasks#1`, `GovOPlaN/govoplan-notifications#1` |
| OpenProject API / project management connector | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#1` |
| Native project-management module | `govoplan-projects`; OpenProject transport remains connector-owned | `GovOPlaN/govoplan-projects#1`, `GovOPlaN/govoplan-connectors#12` |
| Datasources for databases, CSV, files, APIs | `govoplan-datasources`, with external protocol and credential ownership in Connectors | `GovOPlaN/govoplan-datasources#1` |
| Dataflow for pipelines, BI, publication | `govoplan-dataflow` over Datasources and module-owned providers | `GovOPlaN/govoplan-dataflow#1` |
| Monthly datasource and transformation workflows | configuration package across Connectors, Datasources, Dataflow, Workflow Engine, Reporting, Files, and Templates | `GovOPlaN/govoplan#8`, `GovOPlaN/govoplan-dataflow#17` |
| Templates for letters, emails, forms, reports | `govoplan-templates`, separate from reporting | `GovOPlaN/govoplan-core#190`, `GovOPlaN/govoplan-templates#1` |
| Reporting and BI | `govoplan-reporting`, separate from templates | `GovOPlaN/govoplan-core#190`, `GovOPlaN/govoplan-reporting#1` |
| File connectors: Nextcloud, Seafile, SMB, NFS | `govoplan-files` | `GovOPlaN/govoplan-files#15` |
| Public-sector software integration catalogue | `govoplan-connectors` with core strategy index | `GovOPlaN/govoplan-core#191`, `GovOPlaN/govoplan-connectors#2` |
| Public-sector integration landscape catalogue | `govoplan-connectors` with core tracking | `GovOPlaN/govoplan-core#215` |
| Cases module concept | `govoplan-cases` | `GovOPlaN/govoplan-core#174` |
| Workflow runtime/editor split | `govoplan-workflow-engine` runtime plus optional `govoplan-workflow` editor | `GovOPlaN/govoplan-workflow#12`, `GovOPlaN/govoplan-workflow#13` |
| Connectors module concept | `govoplan-connectors` | `GovOPlaN/govoplan-core#176` |
| Adrema-style address and distribution-list management | `govoplan-addresses` | `GovOPlaN/govoplan-addresses#1` |
| Consume sources and become a governed source | Connectors acquires, Datasources governs/materializes, Dataflow transforms/publishes | `GovOPlaN/govoplan-connectors#3`, `GovOPlaN/govoplan-datasources#3` |
| Governed connector configuration, dry-run, and simulation runtime | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#6` |
| Terminfindung and meeting scheduling polls | `govoplan-scheduling`; calendar primitives remain in calendar | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-scheduling#1` |
| Terminplaner and calendar primitives | `govoplan-calendar` | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-calendar#1` |
| Terminbuchung appointment booking | `govoplan-appointments` | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-appointments#1` |
| Collaborative documents | `govoplan-dms` | `GovOPlaN/govoplan-dms#1` |
| Forms | `govoplan-forms` for definitions and `govoplan-forms-runtime` for submissions/runtime behavior | `GovOPlaN/govoplan-core#194`, `GovOPlaN/govoplan-forms#1` |
| RSS consume and emit | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#4` |
| LDAP, Active Directory, OpenDesk identity | `govoplan-idm` | `GovOPlaN/govoplan-idm#1` |
| OpenDesk stack integration map | integration profile across IDM/access, mail/calendar, files/DMS, and connectors; not a monolithic module | `GovOPlaN/govoplan-core#195`, `GovOPlaN/govoplan-connectors#5` |
| Open-Xchange mail/groupware | `govoplan-mail` | `GovOPlaN/govoplan-mail#5` |
| Open-Xchange calendar | `govoplan-calendar` | `GovOPlaN/govoplan-calendar#2` |
| Scalability profiles and autoscaling readiness | `govoplan-ops`, `govoplan-core` | `GovOPlaN/govoplan-core#217` |
| Hardware sizing matrix and requirements calculator | `govoplan-ops`, `govoplan-core` | `GovOPlaN/govoplan-core#219` |
| Collaboration suite integration strategy | `govoplan-connectors`, `govoplan-dms`, `govoplan-workflow`, `govoplan-tasks`, `govoplan-appointments`, `govoplan-calendar` | `GovOPlaN/govoplan-core#220` |
| Install/runtime configuration contract | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#19` |
| Installer/deployment operator flow | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#26` |
| Production-like deployment documentation | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#28` |
Boundary rationale lives in `MODULE_ARCHITECTURE.md`. Current decisions:
- templates and reporting are separate modules
- RSS/source consume-publish starts in connectors; datasources/dataflow are not
repositories yet
- RSS/source consume-publish starts in Connectors; governed source identity and
snapshots belong to Datasources and transformations belong to Dataflow
- calendar, scheduling, and appointments are three separate modules
- forms definitions and forms runtime are separate responsibilities
- OpenDesk is an integration profile across modules, not a monolithic module
- OpenProject is connector-first; no native projects module yet
- OpenProject transport is connector-owned; native portfolio/project semantics
belong to Projects
- public-sector integration strategy stays in core; executable catalogue work
lives in connectors
- encrypted postbox and identity-trust are strategic contracts, not mail-module
behavior
- automation starts as workflow-owned action/effect execution and may split into
a dedicated module only after the runner becomes broader than workflow
The following modules are intentionally not created yet:
- `govoplan-datasources`
- `govoplan-dataflow`
- `govoplan-projects`
Create a repository only after a concrete implementation package proves that
existing connector, files, reporting, workflow, or task ownership is too narrow.
- automation starts in Workflow Engine and may split into a dedicated module
only after the runner becomes broader than workflow
- Mandates, Services, Parties, and Decisions begin as shared semantic contracts;
create repositories only after independent persistence, lifecycle, security,
and multiple-consumer evidence passes the repository threshold in the
institutional governance target architecture
Core keeps the strategy index in
`PUBLIC_SECTOR_INTEGRATION_STRATEGY.md`: integration postures, default
@@ -657,19 +719,24 @@ Release composition and tag-only repository handling are documented in
## Next Practical Work
The next planning step should create or update Gitea issues for Wave 0 and Wave
1 only. Later waves should stay as roadmap context until the permit-to-payment
MVP is demonstrable.
The active cross-product story is
[`GovOPlaN/govoplan#14`](https://git.add-ideas.de/GovOPlaN/govoplan/issues/14).
Module repositories own implementation issues; do not clone their state here.
Recommended immediate issue buckets:
Immediate issue buckets:
- platform spine hardening
- configuration package preflight and rollback
- forms-runtime MVP
- portal submission MVP
- cases/workflow/tasks integration MVP
- template-generated decision document
- postbox/notification handoff
- appointment/booking handoff
- payment evidence handoff
- configured documentation for the reference process
- fail-closed connector destination pinning and private-network deployment
control for every real transport
- Mail-profile-only Campaign authoring/build/delivery and safe legacy failure
- immediate audited secret deletion when a provider/profile is removed
- Campaign central-component and role-safe UI acceptance
- adaptive Campaign, Mail, and Files task/process/admin/operator/integration/
security/acceptance documentation
- target SMTP/IMAP, file-provider, queue/reconciliation, install/upgrade, and
restore proof for the pinned Campaign reference composition
Once that gate is demonstrable, activate the existing Postbox model, access,
API, inbox, and Campaign integration issues. Templates/Reporting, governed BI,
and DMS collaboration remain durable selected direction, but should be
decomposed only as the preceding stage stabilizes or a bounded independent
contract can be implemented without pre-deciding target-system choices.
+159
View File
@@ -0,0 +1,159 @@
# Institutional Context And Governed References
GovOPlaN consequential work must retain enough context to answer who acted,
for whom, through which function, under which mandate and jurisdiction, using
which rule and evidence versions, and with which requested and observed effect.
The shared contract lives in `govoplan_core.core.institutional`.
Core owns reference shapes and provider protocols only. It does not own shared
Mandate, Service, Party, Decision, evidence, or geography tables. Domain modules
own persistence and authorization; optional capabilities resolve the references.
## Envelope
`GovernedContextEnvelope` version 1 carries:
- a tenant and `TemporalRevision` with validity, recording, supersession, and
change reason;
- the real account or service account and represented account, function,
procedure party, assignment, delegation/power, and mandate;
- institution, organization unit, function, task, mandate, jurisdiction,
service, case, party, work item, workflow, approval, decision, and record
references;
- versioned legal bases and evidence references;
- information classification, purposes, retention/holds, minimization, and
disclosure state;
- external-source authority, maturity, freshness, health, and conflict state;
- language, accessibility, channel, explanation, and availability references.
Every institutional reference includes the owner module, tenant, stable object
identity, optional version/effective instant, and a protected display label.
Cross-tenant references are rejected. Safe serialization omits labels,
inspection URLs, formal reasoning, operative results, and conditions unless a
caller explicitly requests the protected projection.
## Semantic Providers
The first provider-neutral capabilities are:
- `mandates.resolver`: resolve competence for a task/authority type at an
effective instant and return the governing Mandate definition and evidence;
- `services.definitions`: obtain versioned institutional service definitions;
- `parties.resolver`: obtain effective procedure-local parties and powers of
representation without copying Identity or Organizations subjects; and
- `decisions.registry`: record and retrieve formal Decisions under optimistic
revision control.
The Mandate, Service, Party/representation, and Decision DTOs have strict
mapping round-trips so they can cross capability, event, package, and storage
boundaries without shared ORM models. Their lifecycle states are explicit:
Mandates distinguish draft/active/suspended/replaced/retired, Services retain
publication state, Parties retain effective representation and revocation, and
Decisions retain correction, revocation, and supersession references.
The DTOs are a repository threshold, not a mandate to create four modules.
Independent persistence, lifecycle, security/operations behavior, release
reason, reuse, and tests are still required before extraction.
Mandate resolution is deterministic: Core filters candidates by tenant,
effective interval, active state, task and authority type, stable
organization/function identity, jurisdiction coverage, and subject type. A
result is competent only when exactly one matching Mandate remains and it has
no unresolved conflicts. Evidence from matching definitions is deduplicated
and retained in the explanation result. `revise_mandate_definition` applies
optimistic concurrency and the allowed activation, suspension, replacement,
and retirement transitions while leaving the previous revision immutable.
`revise_formal_decision` provides the equivalent lifecycle primitive for
formal outcomes. Every accepted transition requires a new recorded revision
and change reason, links `supersedes_ref` to the prior version, updates the
authority envelope to the new version, and records explicit correction or
revocation provenance. Terminal and backward transitions fail closed. Each
Decision also records whether responsibility was human, human-reviewed
automation, or an automated service account acting under mandate. Automation
preparation/recommendation references remain inspectable without being
mistaken for the responsible outcome.
Procedure-party corrections use `revise_procedure_party`: the stable party
identity is retained, a new revision and reason are required, stale writes are
rejected, and revoked/expired/superseded assignments are terminal.
`revoke_party_representation` separately records when a limited power ceased
to authorize actions. This allows consuming procedures to evaluate historical
delivery or representation authority without rewriting Identity,
Organizations, or Addresses records.
Service templates and package/tenant specializations use
`derive_service_restriction`. The derived definition retains an explicit
parent-version reference, cannot extend the parent's validity, audience,
channels, or publication ceiling, and cannot remove inherited prerequisites,
required evidence, legal bases, or bindings. This is the fail-closed semantic
rule; configuration-package signature and provenance checks remain the package
transport rule.
`ServiceAvailabilityRequirement` represents module, capability, mandate,
policy, connector, maintenance, audience, and configuration prerequisites with
an explicit unavailable-or-hidden failure mode and explanation reference. The
optional `services.availability` evaluator returns policy-scoped boolean
assessments, reason codes, and evidence. Unknown consequential requirements
fail closed; a reference itself never grants access.
## Service Launch
`ServiceLaunchRequest` and `ServiceLaunchResult` define the owner-neutral
boundary between Portal entry and a case, form, or workflow runtime effect.
The request carries the exact published Service definition, exact selected
binding, tenant, acting identity, timezone-aware request time, bounded
parameters, and idempotency key. The result must retain that exact Service and
binding, a same-tenant target reference, optional same-tenant evidence, and
only a relative or credential-free HTTP(S) destination.
`service_launch_capability(kind)` maps bindings to owner capabilities:
- `case` -> `cases.service_launcher`
- `form` -> `forms_runtime.service_launcher`
- `workflow` -> `workflow_engine.service_launcher`
`FormDefinition`, `FormFieldDefinition`, and `forms.definitions` provide the
owner-neutral exact-schema boundary used by Forms Runtime. Definitions carry an
exact tenant/revision, field types/options/constraints/defaults, publication,
draft, attachment, signature, policy, and handoff requirements. Forms owns
those immutable definitions; Forms Runtime persists instances and validation
evidence. A form Service binding uses `<form-id>/<revision>` and the launcher
rejects missing, superseded, unpublished, cross-tenant, or invalid definitions.
Portal may discover and invoke those capabilities but cannot write owner
tables. The owner must revalidate its definition/binding and current
authorization, produce its normal audit/event state, and make replay after an
ambiguous response safe. If the capability is absent, the service is
explainably unavailable. URL-only entries pass through the same launch-time
availability check and destination validation. Forms Runtime now supplies the
definition-aware form launcher when both Forms and Forms Runtime are active;
otherwise Portal continues to fail closed.
## Propagation
`PlatformEvent`, `ActionExecutionRequest`, and `AuditEvent` can carry the
envelope. Audit persistence stores only its safe projection; platform-event
outbox serialization preserves it across asynchronous delivery. A module must
not invent a parallel context dictionary when the shared fields apply.
## First Proof
Committee's `committee.decision_path` capability is the first bounded proof. It
requires one effective, conflict-free Mandate covering the organization unit
function, and jurisdiction, an approval reference, fact evidence, versioned legal bases,
operative result, and reasoning. It emits a reconstructable `FormalDecision`,
including requested/observed effects and information governance. If a Decision
registry is installed it persists there; Committee does not take ownership of
the generic Decision lifecycle.
## Compatibility And Security
- Contract version changes follow Core compatibility policy.
- Unknown tenant or reference-kind combinations fail closed.
- Datetimes that affect authority must be timezone-aware.
- Protected labels, reasoning, evidence inspection links, and source details
remain subject to the owning module's access policy.
- References do not grant access to their targets.
- Evidence and audit payloads must contain stable references/checksums, not
plaintext secrets.
+242 -4
View File
@@ -13,6 +13,10 @@ Policy decision, source provenance, and explain-response contracts are tracked
in [`POLICY_CONTRACTS.md`](POLICY_CONTRACTS.md).
The experimental remote WebUI bundle loading design is tracked in
[`REMOTE_WEBUI_BUNDLES.md`](REMOTE_WEBUI_BUNDLES.md).
The cross-product semantic layers, source-authority modes, and candidate
Mandates, Services, Parties, and Decisions boundaries are canonical in the
meta repository's
[`INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md`](../../govoplan/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
## Layer Model
@@ -24,6 +28,36 @@ The experimental remote WebUI bundle loading design is tracked in
| Business modules | Public-sector workflows | campaigns, cases, forms, approvals, appointments |
| Connector modules | External system integration | FIT-Connect, XÖV/XTA, DMS/eAkte, ERP, IDM |
This table is the technical composition model. The product portfolio uses a
more detailed institutional layer model, but it does not change dependency
direction: Core provides contracts and composition; modules own semantics;
packages compose modules.
## Institutional Semantic Boundaries
Cross-module references must keep these answers distinct:
- Organizations owns where structures, units, and functions exist.
- Identity owns who a subject is; Access owns accounts, roles, permissions,
and authorization decisions; IDM owns effective function assignments.
- A Mandates capability will answer why a unit or function is competent for a
task, jurisdiction, subject, or period. It must not become another RBAC
system.
- A Services capability will own versioned institutional service definitions;
Portal presents and starts them.
- A Parties capability will own procedure-local participant roles,
representation, and delivery authority; it must reference rather than copy
Identity, Organizations, and Addresses subjects.
- A Decisions capability will own formal institutional outcomes and their
authority, facts, rules, reasoning, effects, correction, and review.
Approvals owns review gates, Committee owns deliberation/votes, and Workflow
Engine owns coordination.
Start each missing concept as a versioned DTO/provider contract used by a
bounded journey. A repository is justified only when the concept gains
independent persistence, lifecycle, security/operations behavior, release
reason, and reuse. Core must not store these domain objects.
## Kernel Responsibilities
The kernel target owns:
@@ -74,6 +108,10 @@ The compatibility/deprecation plan for the current split line is:
- reject new cross-module imports that bypass manifests, capabilities, events,
or public module APIs
The retention windows and removal checklist for database bridges,
configuration/export schemas, and runtime/API aliases are defined in
`COMPATIBILITY_POLICY.md`.
## Stable Kernel Contracts
The following contracts are the baseline API that modules can rely on:
@@ -87,20 +125,59 @@ The following contracts are the baseline API that modules can rely on:
- capability factory contract
- access DTO/protocol contracts in `govoplan_core.core.access`
- resource ACL provider contract
- tenant summary provider contract
- bounded reference-option search provider contract
- single-tenant and optional batched tenant summary provider contracts
- tenant delete-veto provider contract
- WebUI module contribution contract
- navigation metadata contract
- command/event envelope contract
- policy decision and source provenance contract in `govoplan_core.core.policy`
- external object reference and integration-maturity contract in
`govoplan_core.core.external_references`
- action/effect preview and execution contract in
`govoplan_core.core.automation`
- workflow definition contribution and runtime-worker contracts
Changes to these contracts must be versioned or accompanied by compatibility shims.
Tenant list pages prefer `tenant_summary_batch_providers`. A batch provider
receives the unique tenant IDs on the current page and returns count mappings
keyed by tenant ID. Missing tenant keys mean that the provider has no counts for
that tenant; provider errors remain visible. Modules that expose only the
single-tenant contract remain compatible through a per-tenant fallback.
Destructive tenant lifecycle planning deliberately continues to use the
single-tenant path so it invokes every registered provider for the target
tenant, independent of ordinary list-page projections.
This list is the Milestone A kernel-contract freeze baseline. New module work
may extend the kernel by adding explicit contracts, but existing contracts must
remain source-compatible through the 0.1.x split line unless a migration shim
and deprecation note are provided.
### Architecture Metadata
`ModuleManifest.architecture` is the backward-compatible, versioned product-
portfolio declaration for:
- module kind and institutional architecture layer;
- evidence-backed maturity claim (`concept`, `scaffold`, `vertical_slice`,
`reference_ready`, `supported`, or `lts`);
- owned and explicitly non-owned concepts;
- supported source-authority modes;
- reference packages, tested providers, and known limits;
- migration, upgrade, recovery, security, operations, and documentation
evidence references.
Core validates the claim and all provider references during registry startup.
`reference_ready`, `supported`, and `lts` claims require a named reference
package and the cumulative evidence set; target-tested providers additionally
require provider evidence. A supported module with migrations must include
migration evidence. Signed release catalogs retain and revalidate the
declaration. The meta manifest check validates repository evidence paths and
provides an opt-in `--require-architecture` rollout gate. Platform metadata,
Docs, and Ops project the same declaration. A module cannot make itself
supported solely by changing its maturity string.
Known access-related capability names are defined in
`govoplan_core.core.access`, including:
@@ -134,6 +211,16 @@ Other stable runtime capabilities currently include:
- `calendar.outbox` and `calendar.scheduling`
- `poll.scheduling`
- `notifications.dispatch`
- `workflow.definitionContributions` and `workflow.runtimeWorker`
Modules contribute reusable process baselines through
`ModuleManifest.workflow_definitions`. Each contribution pins its origin module
and version, stable key, schema and content hash, native graph/BPMN content,
governance ceilings, execution mode, and required capabilities/interfaces.
`govoplan-workflow-engine` reconciles these declarations idempotently. A module
upgrade appends a baseline revision without replacing the active revision or
mutating a local override; the optional `govoplan-workflow` package supplies
the comparison, derivation, and reset UI.
### Named Interface Contracts
@@ -156,6 +243,45 @@ intended for SemVer major-version lines. Missing optional interfaces are
allowed, but an installed provider with an incompatible version blocks
activation because the integration would otherwise bind to an unsafe API.
### Source Authority And Provider Operations
Integration maturity and configured authority are independent. The existing
external-reference maturity ladder describes whether an adapter can discover,
link, search, read, publish, synchronize, migrate, or replace. A binding must
also state whether GovOPlaN is native authoritative, the external system is
authoritative, GovOPlaN keeps a mirror, both sides use governed sync, GovOPlaN
adds only a governance overlay, or the object is link-only.
`ModuleManifest.external_providers` composes existing contracts rather than
replacing them. Each declaration describes owned object/field groups, authority modes,
operations, revisions, freshness, health, limits, idempotency, conflicts,
outcome-unknown handling, evidence, correction/compensation, reconciliation,
outage behavior, classification, purpose, retention, and secret requirements.
Core owns the typed declaration and validation. Connectors and domain modules
own the actual protocol and domain behavior; configuration packages select the
effective mode and block missing/incompatible/unhealthy providers; Docs and Ops
explain the result. Effect-capable declarations fail validation unless their
retry, concurrency, outcome-unknown, correction, reconciliation, evidence,
audit, timeout, outage, classification, purpose, retention, and secret behavior
is explicit.
Declarations are release-time capability claims. Configured state is projected
separately through `ModuleManifest.external_provider_state_providers`. A state
provider receives a bounded tenant context and returns one sanitized observation
per configured binding: stable binding reference, effective authority mode,
active/configured state, health, freshness, conflict, recovery readiness,
observation/last-success time, and scalar metrics. Core validates and aggregates
those observations, isolates provider failures, and never accepts URLs,
credentials, or arbitrary nested metadata as runtime-state fields. Docs removes
binding-level detail from ordinary-user projections; Ops may show the full
sanitized operator projection.
Configuration-package preflight selects the exact requested binding from this
runtime state before evaluating authority, health, freshness, and recovery. A
healthy sibling binding therefore cannot mask an unhealthy required binding.
Providers with multiple configurations must use non-secret, stable references
such as `calendar:sync-source:<id>`.
Current named interfaces, generated from the source manifests by the workspace
contract checks, are:
@@ -263,6 +389,23 @@ unsafe methods.
This avoids retransmitting unchanged snapshots. It does not identify which row
changed inside a collection.
### Mutation Preconditions
Weak response ETags are cache validators only. Mutable aggregates expose a
separate positive, monotonic revision and an opaque strong ETag generated by
`govoplan_core.core.concurrency.strong_resource_etag`. HTTP mutations send that
strong ETag in `If-Match`; capability and worker calls carry the equivalent
typed `expected_revision`.
Core's compare-and-set primitive advances the revision in the same transaction
as the domain mutation. A missing HTTP precondition is `428 Precondition
Required`, a stale HTTP precondition is `412 Precondition Failed`, and a
domain/reconciliation conflict is `409 Conflict`. Conflict responses contain
bounded resource and revision metadata rather than the complete current
object. Modules may opt into the conservative three-way merge helper, but must
declare protected workflow, delivery, ownership, lock, evidence, signature,
and cryptographic paths that can never be merged automatically.
### Delta Collections
Collection endpoints that can expose row-level changes should use the shared
@@ -333,6 +476,31 @@ full snapshot with `full: true`. A first-use `seq:0` watermark remains valid
until such a floor exists, even if unrelated collections have advanced the
global sequence.
### Bounded Reference Selectors
Cross-module selectors use the module-neutral contract in
`govoplan_core.core.references`; consumers must not load an optional module's
complete directory and filter it in memory.
- Providers receive a normalized `ReferenceSearchRequest` with `kind`,
`tenant_id`, `query`, `selected_values`, `limit`, optional opaque `cursor`,
and policy context.
- Providers apply visibility and text filtering before materializing rows and
return `ReferenceSearchPage(options, next_cursor, has_more)`.
- A page contains at most the requested bounded search results. Already-selected
references are retained in addition to that bound so historical values remain
readable and removable even when they are inactive, deleted, or outside the
current search page.
- API consumers expose `next_cursor` and `has_more`. The current searchable
selector requests the first bounded page for each query; later load-more UI
can use the same cursor without changing the provider contract.
- `access.reference_options` supplies SQL-backed account, membership, and group
searches. When it is absent, Core degrades to the legacy Access directory or
to principal-only/unavailable references without importing Access.
- The shared WebUI `apiReferenceOptionProvider` resolves selected values in
chunks of at most 200, preventing a large existing selection from turning
into an unbounded request.
### Cursor/Keyset Pages
Offset pagination remains supported for compatibility and for first page loads,
@@ -453,6 +621,18 @@ The manifest should declare:
- navigation metadata using serializable icon names
- uninstall guard providers for data, migration, worker, or scheduler vetoes
A tenant-level managed `RoleTemplate` may set `default_authenticated=True`
only when every authenticated tenant member must receive that narrow baseline
while the contributing module is installed. Access derives the explicit grant
from the active manifest set during authorization without mutating the request
transaction. It may materialize a non-assignable role row for administration,
but no per-user assignment is required and role edits cannot remove the
baseline.
This is not a shortcut for feature authorization: keep the template narrow and
continue to enforce each domain action's own permission and resource policy.
System-level, unmanaged, wildcard-bearing, or slug-colliding automatic
templates are rejected by registry validation.
Backend nav metadata must use icon-name strings, not frontend components:
```python
@@ -515,6 +695,39 @@ Rules:
one migration run, and do not switch a database between tracks unless it is a
disposable development database.
### Shared State And Runtime Ordering
Multi-host application roles use the `shared` state profile. In that profile,
PostgreSQL, Redis, a stable installation identifier, and S3-compatible object
storage are mandatory. Module durable artifacts must use Core's object-storage
contract and module-owned opaque key namespaces; node-local paths are limited
to temporary materialization. Same-host replicas may use the `host-shared`
profile and one shared volume.
Only the migration command mutates schema. PostgreSQL migration runs acquire a
deployment-wide advisory lock before module pre-tasks, Alembic, and post-tasks.
API, worker, and scheduler roles wait for exact configured migration heads and
fail closed instead of applying migrations during startup.
Runtime roles register identity, software/module composition, queues, heartbeat,
and drain state in PostgreSQL. Singleton work must use a distributed lease and
validate its monotonically increasing fencing token at the consequential
commit. See `STATE_AND_RECOVERY_CONTRACT.md` for the complete contract.
### Recovery Evidence
Operations spanning transactions, object storage, queues, or external systems
must choose an explicit Core recovery mode: atomic, compensation,
snapshot-restore, forward-recovery, or irreversible. Plans require verification
steps and mode-specific recovery material. Use idempotency keys, append-only
evidence checkpoints, and a runtime fence where work may race across nodes.
The recovery ledger is a shared primitive, not automatic coverage. A module may
claim its guarantees only after its operation records preconditions before side
effects, transitions partial/unknown outcomes honestly, and records verified
completion or recovery. Plaintext secrets must never enter recovery metadata or
evidence.
## Install, Uninstall, And Catalogs
Core owns the install plan, signed catalog validation, license entitlement
@@ -573,11 +786,18 @@ Uninstall remains non-destructive unless the operator explicitly requests
## WebUI Contract
A WebUI module exports a `PlatformWebModule` from its package. The object contributes local/fallback metadata and route render functions.
A WebUI module exports a `PlatformWebModule` from its package. The object
contributes local/fallback metadata and route render functions. The package
must ship `src/module.ts` with the default contribution export: Core's Vite
host imports that descriptor directly after the backend reports the module as
enabled. This keeps package-root re-exports from pulling page implementations
into the initial shell.
Example:
```ts
const FilesPage = lazy(() => import("./features/files/FilesPage"));
export const filesModule: PlatformWebModule = {
id: "files",
label: "Files",
@@ -592,6 +812,11 @@ export const filesModule: PlatformWebModule = {
};
```
Route pages and substantial panels must use stable lazy imports. Core supplies
the shared loading and retryable error state around route rendering. The
initial static import closure and largest asynchronous chunk are enforced by
the budgets documented in [WEBUI_BUNDLE_BUDGETS.md](WEBUI_BUNDLE_BUDGETS.md).
WebUI modules receive only the core route context:
- `settings`
@@ -809,7 +1034,7 @@ First slice:
- `govoplan-files` owns file-backed governed locations and uploaded/stored file
evidence.
- `govoplan-reporting` owns report/data views and scheduled outputs.
- `govoplan-workflow` owns process state, approvals, scheduling of process
- `govoplan-workflow-engine` owns process state, approvals, scheduling of process
steps, and human review.
Future `govoplan-datasources` is justified when GovOPlaN needs a broad source
@@ -872,7 +1097,7 @@ from workflow semantics.
- form definitions, schemas, validation rules, field visibility rules,
localization, versioning, admin editing, and reusable form package fragments
`govoplan-forms-runtime` owns, when implemented:
`govoplan-forms-runtime` owns:
- public/internal submissions, drafts, submitted values, validation evidence,
attachment references, submission receipts, and handoff events
@@ -885,6 +1110,16 @@ Boundary:
- Reporting/dataflow may consume submitted data through governed DTOs or
source lifecycle contracts.
Implemented contract:
- Core owns the provider-neutral `FormDefinition`/`FormFieldDefinition` DTOs.
- Forms persists immutable exact definitions and provides `forms.definitions`.
- Forms Runtime resolves that capability, persists revisioned instances and
events, validates draft/final values, and provides
`forms_runtime.service_launcher`.
- Portal delegates exact `<form-id>/<revision>` bindings and never writes either
owner's tables.
### OpenDesk Integration Profile
Tracking: `govoplan-core#195`, `govoplan-connectors#5`,
@@ -1129,6 +1364,9 @@ the same restart/health set after restoring package and database snapshots.
The installer preflight is intentionally conservative:
- maintenance mode must be active;
- the `shared` state profile blocks in-place package mutation; clustered
installations must roll one verified immutable module composition across all
replicas;
- installed module manifests must be compatible with the supported manifest
contract and current core version;
- uninstalling `tenancy`, `access`, or `admin` is blocked;
+15
View File
@@ -13,6 +13,7 @@ consistent while each module still owns its domain rules.
| RBAC/access policy | `govoplan-access` | access capabilities in `govoplan_core.core.access` | Permission decisions should use access capability contracts. Explain responses should adopt `PolicyDecision` when an API-level explanation is added. |
| Governance defaults | `govoplan-admin` plus `govoplan-access` materializer | admin settings, governance template routes, access materialization capability | System governance can block tenant-local groups, roles, and API keys. |
| Delegation and ownership policy | access/campaign/mail/files modules | capability checks and owner-scoped APIs | Source provenance should use this contract when policies become externally explainable. |
| Definition governance | `govoplan-policy` | capability `policy.definitionGovernance` | Resolves view, edit, run/start, reuse, derive, and automate for system, tenant, group, and user Dataflow/Workflow definitions. |
## Policy Decision
@@ -111,6 +112,20 @@ matching compatibility DTOs only for its legacy admin surface. Admin overview
responses remain module-local because the same counters are exposed from
different menu contexts and are not yet a separately versioned platform API.
## Definition Governance
Dataflow and Workflow submit a `DefinitionGovernanceRequest` using only stable
scope, principal, status, definition-kind, and limit fields. Policy returns a
standard `PolicyDecision`. System definitions may be inherited as read-only;
group and user definitions are visible only in matching contexts. Templates
may be viewed and derived but never run or automated. A derived definition
passes its pinned ancestor limits back through the request context, and Policy
applies those limits as ceilings rather than defaults that can be broadened.
When the capability is absent, modules must not silently emulate cross-scope
inheritance. Their conservative fallback is limited to local tenant
definitions and disables reuse, derivation, and automation.
## Frontend Contract
Policy UIs must:
+29
View File
@@ -57,6 +57,11 @@ The trust layer should provide:
- key rotation and epoch tracking
- recovery policy hooks
Recovery must be organizationally governed. A server-held universal plaintext
key would defeat the E2EE claim; any escrow, threshold recovery, or emergency
grant needs an explicit assurance profile, authority/quorum, audit trail, and
user-visible consequence.
## Role And Function Postboxes
Role-bound access needs special handling. A postbox can be bound to an
@@ -75,6 +80,30 @@ rewrapping service:
Key epochs are required when role membership changes. Older messages may remain
readable according to policy, but new access must use the current epoch.
The function-bound container exists independently of membership. It may remain
vacant and continue to receive ciphertext without falling back to an unrelated
personal mailbox. Zero, one, or several incumbents are valid states. Each
incumbent receives an independently auditable, device-bound wrapped-key path;
the postbox is never copied into their account ownership.
A new assignment or hand-over rotates the function/postbox key epoch. Envelope
encryption permits the normal rotation path to rewrap per-message data keys
rather than rewrite large ciphertext objects; a security policy may require
full content re-encryption for selected compromise or cryptographic-profile
events. The history available to a new incumbent must be selected policy (all
retained history, a bounded historical window, or assignment-time content) and
recorded with the grant.
Delegation is a time-bounded represented-function grant, not a copy or
substitution of the postbox. Expiry or withdrawal stops future key release and
actions. It cannot revoke plaintext already decrypted, printed, exported, or
captured outside the platform. Multiple simultaneous incumbents and delegates
remain distinguishable in key-fetch and action evidence.
Postbox content and signed manifests are immutable. Correction or replacement
creates a linked new object/version; it never silently substitutes ciphertext
or evidence that another actor may already have inspected.
## External Recipients
External recipients may need one-time or time-limited access without a full
+7 -2
View File
@@ -5,6 +5,9 @@ before deciding to replace specialist workflows. This document is the core
strategy index. The executable connector catalogue lives in
`govoplan-connectors/docs/PUBLIC_SECTOR_INTEGRATION_CATALOGUE.md`.
The canonical cumulative maturity model and external-object DTO are documented
in [EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md](EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md).
## Strategy Labels
Use one or more of these labels for every external system family:
@@ -172,8 +175,10 @@ connector or module issue.
queries, untraceable manual transformations.
- MVP test path: publish one report/export as a governed file plus RSS/Atom
entry with checksum, timestamp, and permission check.
- Owner/priority: `govoplan-reporting`, `govoplan-connectors`, possible future
`govoplan-datasources`/`govoplan-dataflow`, Wave 2.
- Owner/priority: `govoplan-reporting`, `govoplan-connectors`,
`govoplan-datasources`, and `govoplan-dataflow`, Wave 2. Reporting owns
presentation/publication, Connectors owns transport, Datasources owns the
governed source/materialization catalogue, and Dataflow owns transformations.
### Public-Sector Protocols And Registries
+41 -23
View File
@@ -40,26 +40,26 @@ cd /mnt/DATA/git/govoplan
Update those refs when cutting a release:
```text
govoplan-tenancy git@git.add-ideas.de:add-ideas/govoplan-tenancy.git v0.1.8
govoplan-organizations git@git.add-ideas.de:add-ideas/govoplan-organizations.git v0.1.8
govoplan-identity git@git.add-ideas.de:add-ideas/govoplan-identity.git v0.1.8
govoplan-idm git@git.add-ideas.de:add-ideas/govoplan-idm.git v0.1.8
govoplan-access git@git.add-ideas.de:add-ideas/govoplan-access.git v0.1.8
govoplan-admin git@git.add-ideas.de:add-ideas/govoplan-admin.git v0.1.8
govoplan-policy git@git.add-ideas.de:add-ideas/govoplan-policy.git v0.1.8
govoplan-audit git@git.add-ideas.de:add-ideas/govoplan-audit.git v0.1.8
govoplan-dashboard git@git.add-ideas.de:add-ideas/govoplan-dashboard.git v0.1.8
govoplan-addresses git@git.add-ideas.de:add-ideas/govoplan-addresses.git v0.1.8
govoplan-files git@git.add-ideas.de:add-ideas/govoplan-files.git v0.1.8
govoplan-mail git@git.add-ideas.de:add-ideas/govoplan-mail.git v0.1.8
govoplan-campaign git@git.add-ideas.de:add-ideas/govoplan-campaign.git v0.1.8
govoplan-calendar git@git.add-ideas.de:add-ideas/govoplan-calendar.git v0.1.8
govoplan-poll git@git.add-ideas.de:add-ideas/govoplan-poll.git v0.1.8
govoplan-scheduling git@git.add-ideas.de:add-ideas/govoplan-scheduling.git v0.1.8
govoplan-notifications git@git.add-ideas.de:add-ideas/govoplan-notifications.git v0.1.8
govoplan-evaluation git@git.add-ideas.de:add-ideas/govoplan-evaluation.git v0.1.8
govoplan-docs git@git.add-ideas.de:add-ideas/govoplan-docs.git v0.1.8
govoplan-ops git@git.add-ideas.de:add-ideas/govoplan-ops.git v0.1.8
govoplan-tenancy git@git.add-ideas.de:GovOPlaN/govoplan-tenancy.git v0.1.8
govoplan-organizations git@git.add-ideas.de:GovOPlaN/govoplan-organizations.git v0.1.8
govoplan-identity git@git.add-ideas.de:GovOPlaN/govoplan-identity.git v0.1.8
govoplan-idm git@git.add-ideas.de:GovOPlaN/govoplan-idm.git v0.1.8
govoplan-access git@git.add-ideas.de:GovOPlaN/govoplan-access.git v0.1.8
govoplan-admin git@git.add-ideas.de:GovOPlaN/govoplan-admin.git v0.1.8
govoplan-policy git@git.add-ideas.de:GovOPlaN/govoplan-policy.git v0.1.8
govoplan-audit git@git.add-ideas.de:GovOPlaN/govoplan-audit.git v0.1.8
govoplan-dashboard git@git.add-ideas.de:GovOPlaN/govoplan-dashboard.git v0.1.8
govoplan-addresses git@git.add-ideas.de:GovOPlaN/govoplan-addresses.git v0.1.8
govoplan-files git@git.add-ideas.de:GovOPlaN/govoplan-files.git v0.1.8
govoplan-mail git@git.add-ideas.de:GovOPlaN/govoplan-mail.git v0.1.8
govoplan-campaign git@git.add-ideas.de:GovOPlaN/govoplan-campaign.git v0.1.8
govoplan-calendar git@git.add-ideas.de:GovOPlaN/govoplan-calendar.git v0.1.8
govoplan-poll git@git.add-ideas.de:GovOPlaN/govoplan-poll.git v0.1.8
govoplan-scheduling git@git.add-ideas.de:GovOPlaN/govoplan-scheduling.git v0.1.8
govoplan-notifications git@git.add-ideas.de:GovOPlaN/govoplan-notifications.git v0.1.8
govoplan-evaluation git@git.add-ideas.de:GovOPlaN/govoplan-evaluation.git v0.1.8
govoplan-docs git@git.add-ideas.de:GovOPlaN/govoplan-docs.git v0.1.8
govoplan-ops git@git.add-ideas.de:GovOPlaN/govoplan-ops.git v0.1.8
```
## WebUI Packages
@@ -151,7 +151,8 @@ Current tag-only module repositories:
- `govoplan-search`
- `govoplan-tasks`
- `govoplan-templates`
- `govoplan-workflow`
- `govoplan-workflow-engine` (headless definitions, migrations, and execution)
- `govoplan-workflow` (optional authoring and inspection WebUI)
- `govoplan-xoev`
- `govoplan-xrechnung`
- `govoplan-xta-osci`
@@ -788,10 +789,27 @@ tools/checks/postgres-integration-check.py \
The script checks migrations and `/health` startup for core-only, files-only,
mail-only, campaign-only, campaign+files, campaign+mail, and full-product
module sets. `--reset-schema` is destructive and must only be used against a
throwaway database.
throwaway database. Before those permutations, the required Core proof runs in
random, test-owned schemas without modifying `public`. It exercises Files' real
credential-owning retirement provider and proves that credential scrubbing,
non-secret audit
insertion, and table retirement commit together; database-injected audit and
DDL failures roll the entire unit back. A 500 ms PostgreSQL `lock_timeout` and
captured backend process IDs also prove that each `DROP TABLE` uses the
installer Session connection instead of waiting through a second connection.
The meta check enables the release-gate flag so missing PostgreSQL configuration
or full-stack test packages are a hard failure; ordinary Core-only test discovery
skips this integration proof. Do not pass `--skip-retirement-atomicity` when
collecting release evidence.
## Migration Baselines
Release migration support follows `COMPATIBILITY_POLICY.md`: every tagged
`0.1.x` installation is a supported upgrade origin, released revision IDs are
immutable, and migration-only reconciliation remains available for at least one
subsequent major release cycle after the matching runtime compatibility path is
removed.
Development migrations may be small and numerous while a feature is moving.
GovOPlaN keeps those detailed migrations on an explicit development track and
publishes reviewed release shortcuts on the release track. Before a stable
@@ -881,7 +899,7 @@ before that baseline, so pre-v0.1.7 development revisions are not release
upgrade targets. Future release-to-release changes must start from a recorded
release baseline and add a new release-track step-up instead of replacing prior
release shortcuts. The tracking issue is
`add-ideas/govoplan-core#223`.
`GovOPlaN/govoplan-core#223`.
## Related Operator Documents
+7 -1
View File
@@ -40,10 +40,16 @@ set +a
The command reports all known blockers at once. Production-like/self-hosted
profiles require explicit `APP_ENV`, `DATABASE_URL`, `MASTER_KEY_B64`,
`ENABLED_MODULES`, and `CORS_ORIGINS`. Production rejects SQLite, development
`ENABLED_MODULES`, `CORS_ORIGINS`, `GOVOPLAN_TRUSTED_HOSTS`, and a deployment-wide decision for
`GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS`. Production rejects SQLite, development
bootstrap, insecure auth cookies, and unsigned catalog trust roots when a
catalog source is configured.
Connector process-secret names and custom CA files are deployment-owned through
the exact, default-empty `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST` and
`GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST`; tenant/API configuration cannot widen
either boundary.
## Production-Like Dev Stack
Use the local production-like wrapper for repeatable rehearsal:
+143
View File
@@ -0,0 +1,143 @@
# State And Recovery Contract
## State Profiles
Core accepts three runtime state profiles:
| Profile | Runtime placement | Durable storage |
| --- | --- | --- |
| `local` | One development process set | Local filesystem is permitted. |
| `host-shared` | Replicas on one host | One host-shared volume plus PostgreSQL and Redis. |
| `shared` | Independent hosts | PostgreSQL, Redis, and S3-compatible object storage. |
All replicas in one installation use one stable
`GOVOPLAN_INSTALLATION_ID`, one immutable module composition, and identical
database, broker, encryption-key, and object-storage bindings. Core rejects
replicas with the `local` profile and rejects `shared` without PostgreSQL,
Redis, S3, and a non-default installation identifier.
`FILE_STORAGE_S3_ENDPOINT_TRUSTED=true` is an explicit deployment trust
declaration for a clean HTTPS S3 origin. It does not authorize a
user-controlled connector endpoint and it is separate from installer-managed
Garage's exact endpoint trust.
## Object Storage
`govoplan_core.core.object_storage` is the shared backend contract for durable
module artifacts. It provides bounded read/write/list/stat/delete operations
for local and S3-compatible storage. Modules own their object-key namespace and
business metadata; Core does not interpret module files.
Rules for modules:
- Store only opaque object keys in business records, never local absolute
paths.
- Use node-local directories only for temporary materialization.
- Verify expected size and digest before consuming consequential artifacts.
- If object creation precedes database commit, compensate successfully created
objects on failure.
- If object deletion fails, retain the database reference and report a retryable
failure rather than claiming deletion.
- Define an orphan-inventory strategy for hard process loss between object
creation and metadata commit.
The Files module delegates its backend implementation to this Core contract.
Campaign generated EML artifacts use a Campaign-owned object prefix and are
read by workers through the same shared backend.
## Runtime Nodes And Leases
API and worker incarnations register in `core_runtime_nodes` with role,
software version, module-composition hash, queues, start time, and heartbeat.
The registration identity includes a process incarnation so a stale process
cannot update a replacement's row.
Drain is durable operator intent:
- an API enters not-ready state after observing drain;
- a worker cancels queue consumers after observing drain;
- cancellation returns an eligible draining node to active state;
- clean shutdown marks the matching incarnation stopped.
Coordination loss also fails closed. An API reports
`coordination_unavailable` from `/health/ready` until a heartbeat succeeds
again. A worker cancels its local queue consumers on any heartbeat or database
failure and only resumes them after its existing incarnation heartbeats
successfully. It never re-registers from the heartbeat path, so a stale worker
cannot reclaim a node identity from its replacement.
`core_distributed_leases` provides installation/resource uniqueness, expiry,
holder incarnation, and monotonically increasing fencing tokens. Lease expiry
does not make an old process harmless by itself: code performing an effect must
assert its exact fence before commit. `govoplan_core.commands.fenced_run`
renews a lease around a subprocess and terminates the child when the lease is
lost. The deployment profiles use it for the singleton scheduler.
## Migration Ordering
Only `govoplan_core.commands.init_db` mutates schema. On PostgreSQL it acquires
a deterministic installation/track advisory lock before pre-migration tasks,
Alembic, and post-migration tasks. The lock is session-scoped and therefore
released if the migration process dies.
Runtime roles run `govoplan_core.commands.wait_for_database`. It waits until
the database has exactly the configured Core/module Alembic heads and never
upgrades schema. This permits a migration Job and runtime Deployments to be
submitted together while keeping startup fail-closed.
## Recovery Ledger
`govoplan_core.core.recovery` provides a durable operation and evidence
contract. Recovery modes are:
- `atomic`: one database transaction, no external effect;
- `compensation`: explicit inverse actions;
- `snapshot_restore`: separately verified backup reference;
- `forward_recovery`: repair/resume the current version;
- `irreversible`: explicit approval, no automated recovery claim.
Every plan requires verification steps. Mode-specific evidence is mandatory.
Operations bind an idempotency key to a canonical request hash, may bind a
runtime fencing token, and emit append-only hash-chained checkpoints. Backup
and approval references are part of the hashed plan evidence. Every low-level
state transition and checkpoint append revalidates the operation's recorded
fence while holding the operation row lock. Plaintext secrets are rejected
from metadata and evidence.
The state machine makes partial and uncertain outcomes visible. A non-atomic
running operation cannot transition directly to ordinary failure, and success
or recovery requires explicit verified checks. Ops projects states requiring
attention, but module behavior gains this guarantee only after it adopts the
ledger around its own side effects.
## Recovery Boundary
Application/configuration rollback and database rollback are not equivalent.
Once an incompatible migration starts, old code may be unsafe even if its image
is available. Deployment automation must switch to forward recovery unless a
coordinated and verified database/object/key backup is restored.
Core does not create production database backups. The deployment owner must
provide backup, retention, encryption, restore verification, and recovery-point
coordination for PostgreSQL, object storage, and encryption keys. The canonical
operator procedure is documented in
`govoplan/docs/RECOVERY_AND_ROLLBACK_GUARANTEES.md`.
## Verification
Focused contracts are covered by:
```sh
/mnt/DATA/git/govoplan/.venv/bin/python -m pytest -q \
tests/test_object_storage.py \
tests/test_runtime_coordination.py \
tests/test_runtime_agents.py \
tests/test_fenced_run.py \
tests/test_migration_lock.py \
tests/test_wait_for_database.py \
tests/test_recovery_guarantees.py
```
Production acceptance additionally requires multi-node failure and coordinated
restore drills against the actual PostgreSQL, Redis, object-store, ingress, and
secret-provider topology.
+28
View File
@@ -0,0 +1,28 @@
# WebUI Theme Contract
GovOPlaN supports `system`, `light`, and `dark` as persisted user preferences.
`system` follows `prefers-color-scheme` live; it is not resolved permanently at
save time. Core applies the resolved mode through `data-theme` on the document
root and exposes the selected preference through `data-theme-preference`.
## Ownership
- Core owns semantic CSS tokens, native `color-scheme`, preference persistence,
the Settings selector, and the shared shell.
- Modules consume semantic tokens such as `--surface`, `--text`, `--line`, and
the status token families. They may define domain aliases whose values resolve
to shared tokens.
- User preference selects the mode. Tenant and system policy may provide a
future default, but must not silently replace an explicit user choice.
- Tenant branding is a separate policy surface and must preserve contrast and
status semantics in both modes.
Do not introduce fixed foreground/background colors in a module merely to make
one mode look correct. Add or reuse a semantic Core token, then define both
light and dark values. Bitmap content and externally authored HTML are exempt,
but their surrounding controls must still use the shared tokens.
`npm run test:theme-contract` verifies the root behavior and representative
Campaign, Calendar, Files, and Mail token consumption. The check runs before a
production WebUI build.
+24
View File
@@ -0,0 +1,24 @@
# Shared fixed-window throttling
Core exposes `govoplan_core.core.throttling` for security-sensitive endpoints
that need bounded fixed-window counters. Subjects are SHA-256 hashed before they
become store keys. A configured Redis instance provides atomic counters shared
across API workers. Development and temporary Redis outages use a bounded
process-local fallback. Production-like startup rejects an enabled login
throttle without `REDIS_URL` unless
`GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE=true` explicitly acknowledges the
single-process limitation. When Redis fails at runtime, local attempts are still
mirrored so losing the distributed store does not reset the active worker's
protection window.
Callers define one or more `ThrottleDimension` values with a controlled
namespace, a subject and a positive limit. They must call `check` before an
expensive verifier, `record` after a failed attempt, and may `reset` the relevant
dimension after successful verification. A blocked decision includes a
`retry_after_seconds` value suitable for an HTTP `Retry-After` header.
The first consumer is Scheduling's anonymous participation password challenge.
Its namespace is `poll-participation-password`; its subject combines tenant,
scheduling request and Poll's non-secret invitation-token fingerprint. Access's
login throttle predates this primitive and should be migrated onto it in a
separate compatibility-preserving slice.
+89 -4
View File
@@ -6,7 +6,7 @@ binding design reference: future implementation should follow these decisions
unless the decision is explicitly revised here and affected screens are updated
to match.
Active tracking issue: `add-ideas/govoplan-core#225`.
Active tracking issue: `GovOPlaN/govoplan-core#225`.
## Operating Rule
@@ -46,6 +46,10 @@ contestability, responsibility, and traceability at the point of action.
| UX-020 | Centrally exported Core components are mandatory wherever their contract covers the interaction. A custom reusable control, presentation primitive, or module-local substitute requires explicit product-owner authorization, a narrowly specific purpose, and documented rationale and scope; it must not duplicate a central component. | Accepted | Core WebUI and all module WebUIs |
| UX-021 | A collection-wide create action belongs in that collection's page heading and is not duplicated in a persistent side panel. When a side panel is the creation surface, it is present for the creation view only. | Accepted | List-detail, directory, and create surfaces |
| UX-022 | Use central `Card` components for logical sections, `DataGrid` for tabular row collections and their ordered actions, and `ToggleSwitch` for boolean settings. Repeatable people/contact editors use one structured row per person with name, email address, and actions; free-form address parsing is reserved for an explicitly designed bulk-import flow. | Accepted | All WebUI forms and collection editors |
| UX-023 | `FieldLabel` is the standard label/help surface for every field that is not self-explanatory. Any field rendered without it must be recorded in the omission register below, including its accessible-name source and rationale. Users may hide inline help markers through their persisted interface preference; the field label itself remains visible. | Accepted | All Core and module forms |
| UX-024 | Explicit `Discard` actions and dirty in-application navigation use the shared `UnsavedChangesProvider` dialog. A page registers save/discard behavior with `useUnsavedDraftGuard`; its Discard button calls `requestDiscard`, and route changes use `useGuardedNavigate` or `requestNavigation`. | Accepted | All create/edit surfaces |
| UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code |
| UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables |
## Confirmed Implementation Decisions
@@ -191,9 +195,10 @@ explicitly retains the exception.
Decision: Scheduling requests provide a concrete reference application of the
universal placement and component rules.
- The `Scheduling requests` header owns one `Add` action.
- Its left panel is used for the creation view only, not as a permanent second
creation affordance.
- The persistent left panel stacks `My scheduling requests` and `Scheduling
requests for me`; it is list context, not a second creation affordance.
- The left panel's `Scheduling requests` header owns one `Add` action. It opens
the shared view/create/edit surface in the right main panel.
- Basic information, Calendar integration, candidate slots, and participants
use the central `Card` component as four logical sections.
- Candidate slots and participants use the central `DataGrid`, including its
@@ -207,6 +212,78 @@ universal placement and component rules.
Equivalent list/create/edit surfaces use the same underlying rules. These are
not Scheduling-local component variants.
### DUE-011: Field Help, Discard, And Table Action Contracts
Decision: the central components own these interactions; modules compose them
instead of reproducing their behavior.
- `FormField` and `ToggleSwitch` already render `FieldLabel`. Direct field
compositions use `FieldLabel` explicitly when the meaning or limitation is
not self-explanatory.
- `help` content is contextual guidance, not the accessible name. The persisted
`show_inline_help_hints` user preference hides only the `InlineHelp` marker by
applying `ui-hide-help-hints` at the document root.
- A dirty editor registers once with `useUnsavedDraftGuard`. An explicit
Discard button calls `useUnsavedChanges().requestDiscard(afterResolve)`; SPA
navigation uses `useGuardedNavigate` or `requestNavigation`. Both paths show
the same shared unsaved-changes dialog. A browser tab/window unload remains a
browser-controlled confirmation because browsers do not permit a custom
modal at that boundary.
- `TableActionGroup` receives the table's stable action set. Use `disabled` and
`disabledReason` for row state; omit an action only when that action does not
belong to the table. `minimumSlots` reserves trailing positions for an empty
row. `DataGridEmptyAction` does this for the standard add/move/remove layout.
- A paginated `DataGrid` has exactly one query owner. Client mode receives the
complete logical row set and applies filtering and sorting before slicing a
page. Server mode receives only the loaded page, requires `onQueryChange`,
and the backend applies every emitted filter/sort before pagination while
returning `totalRows` for the filtered result. Server list filters declare
their complete option domain instead of deriving it from the loaded page.
External filter affordances such as summary-count shortcuts update the
grid's `query` contract; the grid header controls and backend query therefore
always display and execute the same filter state.
- Feedback and confirmation use `Dialog`, `ConfirmDialog`, or
`DismissibleAlert`. They never fall back to `window.alert`.
### DUE-012: Rich HTML Editing Contract
Decision: modules that edit persisted HTML use the central
`WysiwygEditor` exported from `@govoplan/core-webui/wysiwyg`.
- The dedicated subpath is intentional: the editor and its engine remain a
shared Core contract without adding their code to module combinations that
never consume rich-text editing.
- Consumers provide controlled HTML and domain-specific token labels. The
editor owns visual/source switching, formatting, links, images, safe URL
handling, and atomic inline token rendering; it does not own template
semantics or persistence.
- Existing HTML outside the supported visual subset opens in source mode.
Rendering the value must not rewrite it, and users receive an explicit
warning before choosing the visual surface.
- Domain placeholders remain their original serialized text. Atomic token
presentation is an editing aid only, so backend renderers and existing
templates do not need a new storage format.
#### FieldLabel Omission Register
Every Core field surface that intentionally does not render `FieldLabel` is
listed here. Module repositories keep an equivalent register in their durable
UI documentation until a central cross-repository audit is available.
| Core scope | Why `FieldLabel` is omitted | Accessible/context label source |
| --- | --- | --- |
| `PasswordField`, `ColorPickerField`, `DateField`, `TimeField`, and `DateTimeField` input internals | These are label-neutral composite primitives and are placed inside `FormField`/`FieldLabel` by the consuming form. Rendering another label inside the primitive would duplicate it. | Enclosing label; a direct consumer must pass an accessible name and record that direct composition here. |
| `ToggleSwitch` native checkbox | The shared component already renders its visible text through `FieldLabel`; the native input must not render a second label. | The enclosing native label and derived `aria-label`. |
| `FileDropZone` hidden file input | The input is an implementation detail of the labelled keyboard-operable drop target. | Drop target text and `inputLabel`/`aria-label`. |
| `AdminSelectionList` and `DataGrid` list-filter checkboxes | Each option is self-explanatory and already enclosed by its visible option label. | Enclosing native option label. |
| `EmailAddressInput` compact Name and Email fields | These two conventional fields are self-explanatory in the compact address popover; richer address guidance belongs to the enclosing field. | Visible native labels; the free-form editor also has a descriptive `aria-label`. |
| `DataGrid` page-size, filter, and inline cell editors | The surrounding column header/filter heading supplies field context; repeating a labelled help marker in every cell would add noise. | Column header, filter heading/native label, or generated cell `aria-label`. |
| Retention-policy value controls | `PolicyRow` owns the field label, help, effective value, and provenance for its control. | The containing `PolicyRow` label/help contract. |
#### Alert Exception Register
No `window.alert` or global `alert` exception is authorized.
## Implementation Sequence
| Phase | Scope | Output |
@@ -277,6 +354,14 @@ Every new or changed admin/configuration surface should answer:
- Are logical sections, tabular collections, boolean settings, and repeatable
people/contact rows composed with `Card`, `DataGrid`, `ToggleSwitch`, and one
structured row per person respectively?
- Does every non-self-explanatory field use `FieldLabel`, and is every omission
recorded with its rationale and accessible-name source?
- Do explicit Discard and dirty navigation use the shared unsaved-changes
registration/dialog rather than a page-local confirmation?
- Does every row retain the table's action set in the same order, disabling
unavailable actions and reserving the same empty-row slots?
- Is feedback rendered with a central dialog/alert component, with no
unauthorized `window.alert` or global `alert` call?
- If automation is involved, can the user see the trigger, system actor,
observed effects, and failure/manual-intervention state?
- Are technical details available without being the first thing the user sees?
+71
View File
@@ -0,0 +1,71 @@
# WebUI Loading And Bundle Budgets
The Core WebUI host owns the loading boundary for installed module packages.
Vite discovers configured packages at build time, but emits an asynchronous
loader for each package's `src/module.ts` contribution descriptor. At runtime,
Core imports only descriptors whose backend manifests are enabled and identify
the matching `frontend.package_name`.
The direct descriptor entry is intentional. A package root may re-export pages
for consumers; importing that barrel as module wiring can cause those pages to
be evaluated before navigation. Route pages and substantial panels should use
`React.lazy`, and Core wraps routes in the shared loading/error boundary.
## Enforced Budgets
`webui/bundle-budget.json` contains the production limits:
| Measurement | Raw limit | Gzip limit |
| --- | ---: | ---: |
| Initial JavaScript static import closure | 512 KiB | 160 KiB |
| Largest individual asynchronous JavaScript chunk | 384 KiB | 110 KiB |
`npm run build` writes a Vite manifest, measures the entry and its recursive
static imports, writes `dist/bundle-metrics.json`, and fails when either budget
is exceeded. `npm run test:module-permutations` applies the same gate to every
permutation and records the collected results in
`dist/module-permutation-bundle-metrics.json`. In CI, each result is also added
to the step summary.
Budgets are limits, not targets. A change that approaches a limit should add a
new lazy boundary or remove unnecessary entry code instead of raising the
limit without measurement and review.
## 2026-07-30 Baseline
Measurements use the same full-product source tree and Node 22 runtime. The
post-change build additionally includes the Search module in the default and
full-product sets.
| Initial-load measurement | Before | After | Reduction |
| --- | ---: | ---: | ---: |
| JavaScript assets in initial static closure | 1 | 1 | 0% |
| Raw JavaScript | 1,387,043 B | 453,769 B | 67.3% |
| Gzip level 9 | 364,767 B | 141,725 B | 61.1% |
| Brotli quality 11 | 254,797 B | 106,701 B | 58.1% |
| Parse proxy median | 18.776 ms | 7.750 ms | 58.7% |
| Parse proxy p95 | 21.980 ms | 8.739 ms | 60.2% |
The parse proxy constructs a fresh `node:vm` `SourceTextModule` from the entry
source 30 times with a randomized source marker. It is useful for a controlled
before/after comparison, but is not enforced in CI because absolute timings
vary across runners. Transfer budgets use deterministic raw and gzip byte
counts.
The first budgeted full-product build reported:
- initial JavaScript: 453,769 B raw / 141,725 B gzip;
- largest async chunk: `CampaignWorkspace`, 353,724 B raw / 98,142 B gzip.
## Verification
```bash
cd /mnt/DATA/git/govoplan-core/webui
npm run build
npm run check:bundle-budget
npm run test:module-permutations
```
The build gate also catches accidental eager imports: a page pulled into the
entry closure consumes the initial budget, while an oversized page or module
descriptor consumes the asynchronous chunk budget.
@@ -8,7 +8,7 @@
"description": "Portal form, case workflow, task creation, mail notification, payment setup, and access roles for a simple application process.",
"publisher": "ADD ideas",
"category": "workflow",
"artifact_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-configuration-packages.git@application-handling-basic-v0.1.0",
"artifact_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-configuration-packages.git@application-handling-basic-v0.1.0",
"artifact_sha256": "<sha256>",
"required_modules": [
{ "module_id": "portal", "version": ">=0.1.0" },
+500 -4
View File
@@ -81,8 +81,8 @@
],
"recorded_at": "2026-07-11T01:39:45Z",
"release": "0.1.7",
"track": "release",
"squash_policy": "reviewed-manual"
"squash_policy": "reviewed-manual",
"track": "release"
},
{
"heads": [
@@ -165,8 +165,504 @@
],
"recorded_at": "2026-07-20T02:45:41Z",
"release": "0.1.8",
"track": "release",
"squash_policy": "reviewed-manual"
"squash_policy": "reviewed-manual",
"track": "release"
},
{
"heads": [
{
"owner": "govoplan-campaign",
"revision": "4d5e6f7a9203"
},
{
"owner": "govoplan-mail",
"revision": "608192abcdef"
},
{
"owner": "govoplan-poll",
"revision": "6e7f8a9b0c1d"
},
{
"owner": "govoplan-notifications",
"revision": "6f7a8b9c0d1e"
},
{
"owner": "govoplan-idm",
"revision": "8f9a0b1c2d3e"
},
{
"owner": "govoplan-files",
"revision": "a7b8c9d0e1f3"
},
{
"owner": "govoplan-calendar",
"revision": "af1b2c3d4e5f"
},
{
"owner": "govoplan-scheduling",
"revision": "c9d4e7f1a2b3"
},
{
"owner": "govoplan-addresses",
"revision": "e1f2a4b5c6d"
}
],
"owner_heads": [
{
"owner": "govoplan-access",
"revisions": [
"4a5b6c7d8e9f"
]
},
{
"owner": "govoplan-addresses",
"revisions": [
"e1f2a4b5c6d"
]
},
{
"owner": "govoplan-calendar",
"revisions": [
"af1b2c3d4e5f"
]
},
{
"owner": "govoplan-campaign",
"revisions": [
"4d5e6f7a9203"
]
},
{
"owner": "govoplan-core",
"revisions": [
"4f2a9c8e7b6d"
]
},
{
"owner": "govoplan-files",
"revisions": [
"a7b8c9d0e1f3"
]
},
{
"owner": "govoplan-identity",
"revisions": [
"5c6d7e8f9a10"
]
},
{
"owner": "govoplan-idm",
"revisions": [
"8f9a0b1c2d3e"
]
},
{
"owner": "govoplan-mail",
"revisions": [
"608192abcdef"
]
},
{
"owner": "govoplan-notifications",
"revisions": [
"6f7a8b9c0d1e"
]
},
{
"owner": "govoplan-organizations",
"revisions": [
"6d7e8f9a0b1c"
]
},
{
"owner": "govoplan-poll",
"revisions": [
"6e7f8a9b0c1d"
]
},
{
"owner": "govoplan-scheduling",
"revisions": [
"c9d4e7f1a2b3"
]
}
],
"recorded_at": "2026-07-22T02:42:27Z",
"release": "0.1.11",
"squash_policy": "reviewed-manual",
"track": "release"
},
{
"heads": [
{
"owner": "govoplan-mail",
"revision": "608192abcdef"
},
{
"owner": "govoplan-poll",
"revision": "6e7f8a9b0c1d"
},
{
"owner": "govoplan-notifications",
"revision": "6f7a8b9c0d1e"
},
{
"owner": "govoplan-idm",
"revision": "8f9a0b1c2d3e"
},
{
"owner": "govoplan-files",
"revision": "a7b8c9d0e1f3"
},
{
"owner": "govoplan-calendar",
"revision": "af1b2c3d4e5f"
},
{
"owner": "govoplan-scheduling",
"revision": "c9d4e7f1a2b3"
},
{
"owner": "govoplan-campaign",
"revision": "d8b3e2c1f4a5"
},
{
"owner": "govoplan-addresses",
"revision": "e1f2a4b5c6d"
}
],
"owner_heads": [
{
"owner": "govoplan-access",
"revisions": [
"4a5b6c7d8e9f"
]
},
{
"owner": "govoplan-addresses",
"revisions": [
"e1f2a4b5c6d"
]
},
{
"owner": "govoplan-calendar",
"revisions": [
"af1b2c3d4e5f"
]
},
{
"owner": "govoplan-campaign",
"revisions": [
"d8b3e2c1f4a5"
]
},
{
"owner": "govoplan-core",
"revisions": [
"4f2a9c8e7b6d"
]
},
{
"owner": "govoplan-files",
"revisions": [
"a7b8c9d0e1f3"
]
},
{
"owner": "govoplan-identity",
"revisions": [
"5c6d7e8f9a10"
]
},
{
"owner": "govoplan-idm",
"revisions": [
"8f9a0b1c2d3e"
]
},
{
"owner": "govoplan-mail",
"revisions": [
"608192abcdef"
]
},
{
"owner": "govoplan-notifications",
"revisions": [
"6f7a8b9c0d1e"
]
},
{
"owner": "govoplan-organizations",
"revisions": [
"6d7e8f9a0b1c"
]
},
{
"owner": "govoplan-poll",
"revisions": [
"6e7f8a9b0c1d"
]
},
{
"owner": "govoplan-scheduling",
"revisions": [
"c9d4e7f1a2b3"
]
}
],
"recorded_at": "2026-07-22T07:06:21Z",
"release": "0.1.12",
"squash_policy": "reviewed-manual",
"track": "release"
},
{
"heads": [
{
"owner": "govoplan-mail",
"revision": "608192abcdef"
},
{
"owner": "govoplan-poll",
"revision": "6e7f8a9b0c1d"
},
{
"owner": "govoplan-notifications",
"revision": "6f7a8b9c0d1e"
},
{
"owner": "govoplan-idm",
"revision": "8f9a0b1c2d3e"
},
{
"owner": "govoplan-files",
"revision": "a7b8c9d0e1f3"
},
{
"owner": "govoplan-calendar",
"revision": "af1b2c3d4e5f"
},
{
"owner": "govoplan-scheduling",
"revision": "c9d4e7f1a2b3"
},
{
"owner": "govoplan-campaign",
"revision": "d8b3e2c1f4a5"
},
{
"owner": "govoplan-addresses",
"revision": "e1f2a4b5c6d"
}
],
"owner_heads": [
{
"owner": "govoplan-access",
"revisions": [
"4a5b6c7d8e9f"
]
},
{
"owner": "govoplan-addresses",
"revisions": [
"e1f2a4b5c6d"
]
},
{
"owner": "govoplan-calendar",
"revisions": [
"af1b2c3d4e5f"
]
},
{
"owner": "govoplan-campaign",
"revisions": [
"d8b3e2c1f4a5"
]
},
{
"owner": "govoplan-core",
"revisions": [
"4f2a9c8e7b6d"
]
},
{
"owner": "govoplan-files",
"revisions": [
"a7b8c9d0e1f3"
]
},
{
"owner": "govoplan-identity",
"revisions": [
"5c6d7e8f9a10"
]
},
{
"owner": "govoplan-idm",
"revisions": [
"8f9a0b1c2d3e"
]
},
{
"owner": "govoplan-mail",
"revisions": [
"608192abcdef"
]
},
{
"owner": "govoplan-notifications",
"revisions": [
"6f7a8b9c0d1e"
]
},
{
"owner": "govoplan-organizations",
"revisions": [
"6d7e8f9a0b1c"
]
},
{
"owner": "govoplan-poll",
"revisions": [
"6e7f8a9b0c1d"
]
},
{
"owner": "govoplan-scheduling",
"revisions": [
"c9d4e7f1a2b3"
]
}
],
"recorded_at": "2026-07-22T08:39:02Z",
"release": "0.1.13",
"squash_policy": "reviewed-manual",
"track": "release"
},
{
"heads": [
{
"owner": "govoplan-mail",
"revision": "608192abcdef"
},
{
"owner": "govoplan-poll",
"revision": "6e7f8a9b0c1d"
},
{
"owner": "govoplan-notifications",
"revision": "6f7a8b9c0d1e"
},
{
"owner": "govoplan-idm",
"revision": "8f9a0b1c2d3e"
},
{
"owner": "govoplan-files",
"revision": "a7b8c9d0e1f3"
},
{
"owner": "govoplan-calendar",
"revision": "af1b2c3d4e5f"
},
{
"owner": "govoplan-scheduling",
"revision": "c9d4e7f1a2b3"
},
{
"owner": "govoplan-campaign",
"revision": "d8b3e2c1f4a5"
},
{
"owner": "govoplan-addresses",
"revision": "e1f2a4b5c6d"
}
],
"owner_heads": [
{
"owner": "govoplan-access",
"revisions": [
"4a5b6c7d8e9f"
]
},
{
"owner": "govoplan-addresses",
"revisions": [
"e1f2a4b5c6d"
]
},
{
"owner": "govoplan-calendar",
"revisions": [
"af1b2c3d4e5f"
]
},
{
"owner": "govoplan-campaign",
"revisions": [
"d8b3e2c1f4a5"
]
},
{
"owner": "govoplan-core",
"revisions": [
"4f2a9c8e7b6d"
]
},
{
"owner": "govoplan-files",
"revisions": [
"a7b8c9d0e1f3"
]
},
{
"owner": "govoplan-identity",
"revisions": [
"5c6d7e8f9a10"
]
},
{
"owner": "govoplan-idm",
"revisions": [
"8f9a0b1c2d3e"
]
},
{
"owner": "govoplan-mail",
"revisions": [
"608192abcdef"
]
},
{
"owner": "govoplan-notifications",
"revisions": [
"6f7a8b9c0d1e"
]
},
{
"owner": "govoplan-organizations",
"revisions": [
"6d7e8f9a0b1c"
]
},
{
"owner": "govoplan-poll",
"revisions": [
"6e7f8a9b0c1d"
]
},
{
"owner": "govoplan-scheduling",
"revisions": [
"c9d4e7f1a2b3"
]
}
],
"recorded_at": "2026-07-22T18:15:01Z",
"release": "0.1.14",
"squash_policy": "reviewed-manual",
"track": "release"
}
],
"version": 1
+21 -13
View File
@@ -12,9 +12,9 @@
"version": "0.1.4",
"action": "install",
"python_package": "govoplan-files",
"python_ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.4",
"python_ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.4",
"webui_package": "@govoplan/files-webui",
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4",
"webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.4",
"migration_safety": "forward_only",
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
"migration_after": ["access"],
@@ -54,14 +54,14 @@
],
"artifact_integrity": {
"python": {
"ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.4",
"ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.4",
"sha256": "<sha256 of the resolved Python artifact>",
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-0.1.4.spdx.json",
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-0.1.4.intoto.jsonl",
"git_ref": "refs/tags/v0.1.4"
},
"webui": {
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4",
"ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.4",
"sha256": "<sha256 of the resolved WebUI package artifact>",
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-webui-0.1.4.spdx.json",
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-webui-0.1.4.intoto.jsonl",
@@ -78,25 +78,33 @@
"version": "0.1.4",
"action": "install",
"python_package": "govoplan-mail",
"python_ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.4",
"python_ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.4",
"webui_package": "@govoplan/mail-webui",
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4",
"webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.4",
"provides_interfaces": [
{
"name": "mail.campaign_delivery",
"version": "1.4.0"
"version": "0.2.0"
}
],
"requires_interfaces": [
{
"name": "campaigns.access",
"version_min": "0.1.0",
"version_max_exclusive": "0.2.0",
"optional": true
}
],
"artifact_integrity": {
"python": {
"ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.4",
"ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.4",
"sha256": "<sha256 of the resolved Python artifact>",
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-0.1.4.spdx.json",
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-0.1.4.intoto.jsonl",
"git_ref": "refs/tags/v0.1.4"
},
"webui": {
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4",
"ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.4",
"sha256": "<sha256 of the resolved WebUI package artifact>",
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-webui-0.1.4.spdx.json",
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-webui-0.1.4.intoto.jsonl",
@@ -113,19 +121,19 @@
"version": "0.1.6",
"action": "install",
"python_package": "govoplan-dashboard",
"python_ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git@v0.1.6",
"python_ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.6",
"webui_package": "@govoplan/dashboard-webui",
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.6",
"webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.6",
"artifact_integrity": {
"python": {
"ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git@v0.1.6",
"ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.6",
"sha256": "<sha256 of the resolved Python artifact>",
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-0.1.6.spdx.json",
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-0.1.6.intoto.jsonl",
"git_ref": "refs/tags/v0.1.6"
},
"webui": {
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.6",
"ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.6",
"sha256": "<sha256 of the resolved WebUI package artifact>",
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-webui-0.1.6.spdx.json",
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-webui-0.1.6.intoto.jsonl",
+12 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-core"
version = "0.1.9"
version = "0.1.14"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md"
requires-python = ">=3.12"
@@ -19,6 +19,7 @@ dependencies = [
"celery>=5,<6",
"redis>=5,<6",
"alembic>=1,<2",
"boto3>=1.34,<2",
]
[tool.setuptools.packages.find]
@@ -27,6 +28,12 @@ where = ["src"]
[tool.setuptools.package-data]
govoplan_core = ["py.typed"]
[tool.setuptools.data-files]
"govoplan_core_runtime" = ["alembic.ini"]
"govoplan_core_runtime/alembic" = ["alembic/env.py", "alembic/script.py.mako"]
"govoplan_core_runtime/alembic/versions" = ["alembic/versions/*.py"]
"govoplan_core_runtime/alembic/dev_versions" = ["alembic/dev_versions/*.py"]
[project.scripts]
govoplan-config = "govoplan_core.commands.config:main"
govoplan-devserver = "govoplan_core.devserver:main"
@@ -42,3 +49,7 @@ dev = [
"httpx==0.28.1",
"httpx2>=2.5,<3",
]
[tool.ruff.lint.per-file-ignores]
"tests/test_api_smoke.py" = ["E402"]
"tests/test_module_system.py" = ["E402"]
+26 -1
View File
@@ -39,6 +39,24 @@ class DeltaCollectionResponse(BaseModel):
full: bool = False
class ReferenceOptionResponse(BaseModel):
value: str
label: str
description: str | None = None
kind: str | None = None
availability: Literal["available", "inactive", "unavailable"] = "available"
disabled: bool = False
source_module: str | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
class ReferenceOptionListResponse(BaseModel):
options: list[ReferenceOptionResponse] = Field(default_factory=list)
provider_available: bool = True
next_cursor: str | None = None
has_more: bool = False
class LoginRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -55,6 +73,12 @@ class SwitchTenantRequest(BaseModel):
tenant_id: str
class SwitchActingContextRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
assignment_id: str | None = Field(default=None, max_length=36)
class TenantInfo(BaseModel):
id: str
slug: str
@@ -160,6 +184,7 @@ class PrincipalContextInfo(BaseModel):
api_key_id: str | None = None
session_id: str | None = None
service_account_id: str | None = None
acting_assignment_id: str | None = None
acting_for_account_id: str | None = None
email: str | None = None
display_name: str | None = None
@@ -201,7 +226,7 @@ class AuthGroupsResponse(BaseModel):
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
token_type: str = "bearer" # noqa: S105 - OAuth token type, not a credential.
expires_at: datetime
user: UserInfo
# Backwards-compatible alias for the active tenant.
+34 -5
View File
@@ -16,8 +16,9 @@ from govoplan_core.core.events import (
PlatformEvent,
current_event_trace,
normalize_trace_id,
publish_platform_event,
emit_platform_event,
)
from govoplan_core.core.institutional import GovernedContextEnvelope
from govoplan_core.core.runtime import get_registry
from govoplan_core.privacy.retention import sanitize_audit_details_for_policy
from govoplan_core.security.redaction import redact_secret_values
@@ -171,9 +172,23 @@ class _NullAuditRecorder:
)
def _publish_audit_platform_event(item: AuditRecordRef) -> None:
def _publish_audit_platform_event(
session: Session,
item: AuditRecordRef,
) -> None:
trace = _compact_trace(item.details.get("_trace") if isinstance(item.details, Mapping) else None)
publish_platform_event(
raw_context = (
item.details.get("_institutional_context")
if isinstance(item.details, Mapping)
else None
)
institutional_context = (
GovernedContextEnvelope.from_mapping(raw_context)
if isinstance(raw_context, Mapping)
else None
)
emit_platform_event(
session,
PlatformEvent(
type=item.action,
module_id=_module_id_for_audit_action(item.action),
@@ -190,6 +205,7 @@ def _publish_audit_platform_event(item: AuditRecordRef) -> None:
tenant=EventTenantRef(id=item.tenant_id) if item.tenant_id else None,
resource=EventObjectRef(type=item.object_type, id=item.object_id) if item.object_type else None,
classification="internal",
institutional_context=institutional_context,
)
)
@@ -207,6 +223,7 @@ def audit_event(
details: dict[str, Any] | None = None,
correlation_id: str | None = None,
causation_id: str | None = None,
institutional_context: GovernedContextEnvelope | None = None,
commit: bool = False,
) -> AuditRecordRef:
"""Persist one audit event.
@@ -219,8 +236,17 @@ def audit_event(
if scope not in {"tenant", "system"}:
raise ValueError(f"Unsupported audit scope: {scope}")
if (
institutional_context is not None
and tenant_id is not None
and institutional_context.tenant_id != tenant_id
):
raise ValueError("Audit institutional context belongs to another tenant")
raw_details = dict(details or {})
if institutional_context is not None:
raw_details["_institutional_context"] = institutional_context.to_dict()
traced_details, trace = _trace_details(
_sanitize_details(details or {}),
_sanitize_details(raw_details),
correlation_id=correlation_id,
causation_id=causation_id,
)
@@ -238,6 +264,7 @@ def audit_event(
api_key_id=api_key_id,
resource_type=object_type,
resource_id=object_id,
institutional_context=institutional_context,
details=stored_details,
))
record_change(
@@ -252,7 +279,7 @@ def audit_event(
actor_id=user_id or api_key_id,
payload={"scope": scope, "action": action, "object_type": object_type, "object_id": object_id},
)
_publish_audit_platform_event(item)
_publish_audit_platform_event(session, item)
if commit:
session.commit()
return item
@@ -269,6 +296,7 @@ def audit_from_principal(
details: dict[str, Any] | None = None,
correlation_id: str | None = None,
causation_id: str | None = None,
institutional_context: GovernedContextEnvelope | None = None,
commit: bool = False,
) -> AuditRecordRef:
return audit_event(
@@ -283,5 +311,6 @@ def audit_from_principal(
details=details,
correlation_id=correlation_id,
causation_id=causation_id,
institutional_context=institutional_context,
commit=commit,
)
+7 -3
View File
@@ -1,5 +1,3 @@
from __future__ import annotations
"""Core auth dependency facade.
Routers depend on this module instead of a concrete access-provider package.
@@ -7,6 +5,8 @@ The active auth module provides the request principal through the platform
capability registry.
"""
from __future__ import annotations
from dataclasses import dataclass
from fastapi import Depends, Header, HTTPException, Request, status
@@ -81,6 +81,10 @@ class ApiPrincipal:
def acting_for_account_id(self) -> str | None:
return self.principal.acting_for_account_id
@property
def acting_assignment_id(self) -> str | None:
return self.principal.acting_assignment_id
@property
def auth_method(self) -> str:
return self.principal.auth_method
@@ -118,7 +122,7 @@ def _registry_from_request(request: Request) -> PlatformRegistry | None:
def _api_principal_provider_from_request(request: Request) -> ApiPrincipalProvider:
registry = _registry_from_request(request)
if registry is None or not registry.has_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Auth provider is not available")
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Auth provider is not available")
capability = registry.require_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER)
if not isinstance(capability, ApiPrincipalProvider):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid auth provider capability")
+723 -15
View File
@@ -1,17 +1,77 @@
from __future__ import annotations
from celery import Celery
from datetime import datetime, timedelta, timezone
from importlib.metadata import PackageNotFoundError, version
import logging
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_OUTBOX, CalendarOutboxProvider
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from celery import Celery
from celery.signals import heartbeat_sent, worker_ready, worker_shutdown
from govoplan_core.core.campaigns import (
CAPABILITY_CAMPAIGNS_DELIVERY_TASKS,
CampaignDeliveryTaskProvider,
)
from govoplan_core.core.calendar import (
CAPABILITY_CALENDAR_OUTBOX,
CalendarOutboxProvider,
)
from govoplan_core.core.dataflows import (
CAPABILITY_DATAFLOW_RUN_WORKER,
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER,
DataflowRunWorker,
DataflowTriggerDispatcher,
)
from govoplan_core.core.events import (
CAPABILITY_PLATFORM_EVENT_OUTBOX,
DurableEventConsumer,
PlatformEvent,
PlatformEventOutbox,
publish_platform_event,
)
from govoplan_core.core.idm import (
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE,
IdmAssignmentLifecycle,
)
from govoplan_core.core.module_management import (
load_startup_enabled_modules,
startup_candidate_module_ids,
)
from govoplan_core.core.mail import (
CAPABILITY_MAIL_BOUNCE_PROCESSING,
CAPABILITY_MAIL_DELIVERY_OUTBOX,
MailBounceProcessingProvider,
MailDeliveryOutboxProvider,
)
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
from govoplan_core.core.notifications import (
CAPABILITY_NOTIFICATIONS_DISPATCH,
NotificationDispatchProvider,
)
from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_ROUTING,
PostboxRoutingProvider,
)
from govoplan_core.core.workflows import (
CAPABILITY_WORKFLOW_RUNTIME_WORKER,
CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER,
WorkflowRuntimeWorker,
WorkflowTriggerDispatcher,
)
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import configure_runtime
from govoplan_core.core.runtime_coordination import (
RuntimeIdentity,
heartbeat_runtime_node,
register_runtime_node,
runtime_identity,
stop_runtime_node,
)
from govoplan_core.settings import settings
from govoplan_core.db.session import configure_database
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.db.session import configure_database, get_database
from govoplan_core.server.registry import (
available_module_manifests,
build_platform_registry,
)
configure_database(settings.database_url)
@@ -28,7 +88,18 @@ celery.conf.update(
"govoplan.campaigns.append_sent": {"queue": "append_sent"},
"govoplan.notifications.deliver": {"queue": "notifications"},
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
"govoplan.mail.dispatch_outbox": {"queue": "mail"},
"govoplan.mail.purge_outbox": {"queue": "mail"},
"govoplan.mail.scan_bounces": {"queue": "mail"},
"govoplan.calendar.dispatch_outbox": {"queue": "calendar"},
"govoplan.dataflow.dispatch_runs": {"queue": "dataflow"},
"govoplan.dataflow.purge_runs": {"queue": "dataflow"},
"govoplan.dataflow.dispatch_triggers": {"queue": "dataflow"},
"govoplan.workflow.reconcile": {"queue": "workflow"},
"govoplan.postbox.dispatch_routes": {"queue": "postbox"},
"govoplan.events.dispatch_outbox": {"queue": "events"},
"govoplan.events.purge_outbox": {"queue": "events"},
"govoplan.idm.expire_assignments": {"queue": "idm"},
},
worker_prefetch_multiplier=1,
task_acks_late=True,
@@ -39,9 +110,177 @@ celery.conf.update(
"schedule": 60.0,
"args": (None, 100),
},
"mail-outbox-every-five-seconds": {
"task": "govoplan.mail.dispatch_outbox",
"schedule": 5.0,
"args": (None, 25),
},
"mail-outbox-retention-daily": {
"task": "govoplan.mail.purge_outbox",
"schedule": 24 * 60 * 60.0,
"args": (250,),
},
"mail-bounces-every-five-minutes": {
"task": "govoplan.mail.scan_bounces",
"schedule": 5 * 60.0,
"args": (None, 250),
},
"dataflow-triggers-every-minute": {
"task": "govoplan.dataflow.dispatch_triggers",
"schedule": 60.0,
"args": (100,),
},
"dataflow-runs-every-five-seconds": {
"task": "govoplan.dataflow.dispatch_runs",
"schedule": 5.0,
"args": (10,),
},
"dataflow-run-retention-daily": {
"task": "govoplan.dataflow.purge_runs",
"schedule": 24 * 60 * 60.0,
"args": (500,),
},
"workflow-reconcile-every-five-seconds": {
"task": "govoplan.workflow.reconcile",
"schedule": 5.0,
"args": (50,),
},
"postbox-routes-every-minute": {
"task": "govoplan.postbox.dispatch_routes",
"schedule": 60.0,
"args": (None, 50),
},
"platform-events-every-ten-seconds": {
"task": "govoplan.events.dispatch_outbox",
"schedule": 10.0,
"args": (100,),
},
"platform-event-retention-daily": {
"task": "govoplan.events.purge_outbox",
"schedule": 24 * 60 * 60.0,
"args": (500,),
},
"idm-assignment-expiry-every-minute": {
"task": "govoplan.idm.expire_assignments",
"schedule": 60.0,
"args": (None, 100),
},
},
)
_worker_identity: RuntimeIdentity | None = None
_worker_draining = False
_worker_consumer: object | None = None
logger = logging.getLogger("govoplan.worker.runtime")
def _core_version() -> str:
try:
return version("govoplan-core")
except PackageNotFoundError:
return "development"
def _worker_runtime_identity(sender: object | None = None) -> RuntimeIdentity:
global _worker_identity
if _worker_identity is None:
hostname = str(getattr(sender, "hostname", "") or "").strip() or None
module_ids = tuple(load_startup_enabled_modules(settings.enabled_modules))
_worker_identity = runtime_identity(
settings,
software_version=_core_version(),
module_ids=module_ids,
role="worker",
node_id=hostname,
)
return _worker_identity
@worker_ready.connect
def _register_worker_runtime(sender=None, **_kwargs) -> None:
global _worker_consumer
_worker_consumer = sender
identity = _worker_runtime_identity(sender)
try:
with get_database().SessionLocal() as session:
node = register_runtime_node(
session,
identity,
metadata={"process": "celery-worker"},
)
session.commit()
except Exception:
_set_worker_consumers(identity, draining=True)
logger.exception(
"worker runtime registration failed; queues were disabled node_id=%s",
identity.node_id,
)
raise
_set_worker_consumers(identity, draining=node.state == "draining")
@heartbeat_sent.connect
def _heartbeat_worker_runtime(sender=None, **_kwargs) -> None:
identity = _worker_runtime_identity(sender)
try:
with get_database().SessionLocal() as session:
node = heartbeat_runtime_node(
session,
identity,
metadata={"process": "celery-worker"},
)
session.commit()
except Exception: # noqa: BLE001 - uncertain authority must fail closed
_set_worker_consumers(identity, draining=True)
logger.exception(
"worker runtime heartbeat failed; queues were disabled node_id=%s",
identity.node_id,
)
return
_set_worker_consumers(identity, draining=node.state == "draining")
def _set_worker_consumers(
identity: RuntimeIdentity,
*,
draining: bool,
) -> None:
global _worker_draining
if draining == _worker_draining:
return
consumer = _worker_consumer
if consumer is not None:
method_name = "cancel_task_queue" if draining else "add_task_queue"
method = getattr(consumer, method_name)
destination = None
else:
method = (
celery.control.cancel_consumer if draining else celery.control.add_consumer
)
destination = [identity.node_id]
for queue in identity.queues:
if destination is None:
method(queue)
else:
method(queue, destination=destination)
_worker_draining = draining
@worker_shutdown.connect
def _stop_worker_runtime(sender=None, **_kwargs) -> None:
identity = _worker_identity
if identity is None:
return
try:
with get_database().SessionLocal() as session:
stop_runtime_node(session, identity)
session.commit()
except Exception: # noqa: BLE001 - shutdown must continue
logger.exception(
"worker runtime stop marker failed node_id=%s",
identity.node_id,
)
@celery.task(name="govoplan.ping")
def ping():
@@ -50,9 +289,15 @@ def ping():
def _platform_registry() -> PlatformRegistry:
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules)
available_modules = available_module_manifests(enabled_modules=candidate_modules, ignore_load_errors=True)
enabled_modules = load_startup_enabled_modules(settings.enabled_modules, available=available_modules)
candidate_modules = startup_candidate_module_ids(
settings.enabled_modules, raw_enabled_modules
)
available_modules = available_module_manifests(
enabled_modules=candidate_modules, ignore_load_errors=True
)
enabled_modules = load_startup_enabled_modules(
settings.enabled_modules, available=available_modules
)
registry = build_platform_registry(enabled_modules)
context = ModuleContext(registry=registry, settings=settings)
configure_runtime(context)
@@ -86,6 +331,110 @@ def _calendar_outbox() -> CalendarOutboxProvider | None:
return capability
def _mail_delivery_outbox() -> MailDeliveryOutboxProvider | None:
registry = _platform_registry()
if not registry.has_capability(CAPABILITY_MAIL_DELIVERY_OUTBOX):
return None
capability = registry.require_capability(CAPABILITY_MAIL_DELIVERY_OUTBOX)
if not isinstance(capability, MailDeliveryOutboxProvider):
raise RuntimeError("Mail delivery outbox capability is invalid")
return capability
def _mail_bounce_processing() -> MailBounceProcessingProvider | None:
registry = _platform_registry()
if not registry.has_capability(CAPABILITY_MAIL_BOUNCE_PROCESSING):
return None
capability = registry.require_capability(CAPABILITY_MAIL_BOUNCE_PROCESSING)
if not isinstance(capability, MailBounceProcessingProvider):
raise RuntimeError("Mail bounce-processing capability is invalid")
return capability
def _dataflow_trigger_dispatcher(
registry: PlatformRegistry | None = None,
) -> DataflowTriggerDispatcher | None:
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER):
return None
capability = registry.require_capability(CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER)
if not isinstance(capability, DataflowTriggerDispatcher):
raise RuntimeError("Dataflow trigger dispatcher capability is invalid")
return capability
def _dataflow_run_worker(
registry: PlatformRegistry | None = None,
) -> DataflowRunWorker | None:
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_DATAFLOW_RUN_WORKER):
return None
capability = registry.require_capability(CAPABILITY_DATAFLOW_RUN_WORKER)
if not isinstance(capability, DataflowRunWorker):
raise RuntimeError("Dataflow run worker capability is invalid")
return capability
def _workflow_runtime_worker(
registry: PlatformRegistry | None = None,
) -> WorkflowRuntimeWorker | None:
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_WORKFLOW_RUNTIME_WORKER):
return None
capability = registry.require_capability(CAPABILITY_WORKFLOW_RUNTIME_WORKER)
if not isinstance(capability, WorkflowRuntimeWorker):
raise RuntimeError("Workflow runtime worker capability is invalid")
return capability
def _workflow_trigger_dispatcher(
registry: PlatformRegistry | None = None,
) -> WorkflowTriggerDispatcher | None:
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER):
return None
capability = registry.require_capability(CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER)
if not isinstance(capability, WorkflowTriggerDispatcher):
raise RuntimeError("Workflow trigger dispatcher capability is invalid")
return capability
def _postbox_routing_provider(
registry: PlatformRegistry | None = None,
) -> PostboxRoutingProvider | None:
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_POSTBOX_ROUTING):
return None
capability = registry.require_capability(CAPABILITY_POSTBOX_ROUTING)
if not isinstance(capability, PostboxRoutingProvider):
raise RuntimeError("Postbox routing capability is invalid")
return capability
def _platform_event_outbox(
registry: PlatformRegistry | None = None,
) -> PlatformEventOutbox | None:
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX):
return None
capability = registry.require_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
if not isinstance(capability, PlatformEventOutbox):
raise RuntimeError("Platform event outbox capability is invalid")
return capability
def _idm_assignment_lifecycle(
registry: PlatformRegistry | None = None,
) -> IdmAssignmentLifecycle | None:
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE):
return None
capability = registry.require_capability(CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE)
if not isinstance(capability, IdmAssignmentLifecycle):
raise RuntimeError("IDM assignment lifecycle capability is invalid")
return capability
@celery.task(name="govoplan.campaigns.send_email", bind=True, max_retries=0)
def send_email(self, job_id: str):
"""Send one explicitly queued campaign job.
@@ -98,7 +447,11 @@ def send_email(self, job_id: str):
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
return dict(_campaign_delivery_tasks().send_campaign_job(session, job_id=job_id, enqueue_imap_task=True))
return dict(
_campaign_delivery_tasks().send_campaign_job(
session, job_id=job_id, enqueue_imap_task=True
)
)
@celery.task(name="govoplan.campaigns.append_sent", bind=True, max_retries=None)
@@ -109,7 +462,9 @@ def append_sent(self, job_id: str):
with get_database().SessionLocal() as session:
try:
return dict(_campaign_delivery_tasks().append_sent_for_job(session, job_id=job_id))
return dict(
_campaign_delivery_tasks().append_sent_for_job(session, job_id=job_id)
)
except Exception as exc:
if getattr(exc, "temporary", None) is True:
raise self.retry(exc=exc, countdown=300)
@@ -121,7 +476,11 @@ def deliver_notification(self, notification_id: str):
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
result = dict(_notification_dispatch().deliver_notification(session, notification_id=notification_id))
result = dict(
_notification_dispatch().deliver_notification(
session, notification_id=notification_id
)
)
session.commit()
return result
@@ -131,7 +490,80 @@ def deliver_pending_notifications(self, tenant_id: str | None = None, limit: int
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
result = dict(_notification_dispatch().deliver_pending(session, tenant_id=tenant_id, limit=limit))
result = dict(
_notification_dispatch().deliver_pending(
session, tenant_id=tenant_id, limit=limit
)
)
session.commit()
return result
@celery.task(name="govoplan.mail.dispatch_outbox", bind=True, max_retries=0)
def dispatch_mail_outbox(
self,
tenant_id: str | None = None,
limit: int = 25,
):
"""Drain durable Mail commands; retry and reconciliation live in Mail."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
provider = _mail_delivery_outbox()
if provider is None:
return {
"selected": 0,
"accepted": 0,
"partially_refused": 0,
"retrying": 0,
"failed": 0,
"outcome_unknown": 0,
"command_ids": [],
}
return dict(
provider.dispatch_due(
session,
tenant_id=tenant_id,
limit=limit,
worker_id=getattr(self.request, "hostname", None),
)
)
@celery.task(name="govoplan.mail.purge_outbox", bind=True, max_retries=0)
def purge_mail_outbox(self, limit: int = 250):
"""Minimize expired Mail payloads while retaining delivery evidence."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
provider = _mail_delivery_outbox()
if provider is None:
return {"purged": 0}
return dict(provider.purge_expired(session, limit=limit))
@celery.task(name="govoplan.mail.scan_bounces", bind=True, max_retries=0)
def scan_mail_bounces(
self,
tenant_id: str | None = None,
limit: int = 250,
):
"""Read configured DSN folders without mutating provider mailbox flags."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
provider = _mail_bounce_processing()
if provider is None:
return {
"sources": 0,
"processed_messages": 0,
"observations": 0,
"failures": [],
}
result = dict(provider.scan_due(session, tenant_id=tenant_id, limit=limit))
session.commit()
return result
@@ -145,7 +577,283 @@ def dispatch_calendar_outbox(self, tenant_id: str | None = None, limit: int = 50
with get_database().SessionLocal() as session:
provider = _calendar_outbox()
if provider is None:
return {"processed": 0, "succeeded": 0, "retrying": 0, "failed": 0, "operations": []}
return {
"processed": 0,
"succeeded": 0,
"retrying": 0,
"failed": 0,
"operations": [],
}
result = dict(provider.dispatch_due(session, tenant_id=tenant_id, limit=limit))
session.commit()
return result
@celery.task(
name="govoplan.dataflow.dispatch_triggers",
bind=True,
max_retries=0,
)
def dispatch_dataflow_triggers(self, limit: int = 100):
"""Drain durable Dataflow trigger deliveries and due schedules."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
provider = _dataflow_trigger_dispatcher()
if provider is None:
return {
"queued": 0,
"processed": 0,
"succeeded": 0,
"failed": 0,
"blocked": 0,
"skipped": 0,
}
result = dict(provider.dispatch_due(session, limit=limit))
session.commit()
return result
@celery.task(
name="govoplan.dataflow.dispatch_runs",
bind=True,
max_retries=0,
)
def dispatch_dataflow_runs(self, limit: int = 10):
"""Claim and execute durable Dataflow runs outside the API process."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
provider = _dataflow_run_worker()
if provider is None:
return {
"claimed": 0,
"succeeded": 0,
"retrying": 0,
"failed": 0,
"cancelled": 0,
}
result = dict(
provider.dispatch_pending(
session,
limit=limit,
worker_id=getattr(self.request, "hostname", None),
)
)
session.commit()
return result
@celery.task(
name="govoplan.dataflow.purge_runs",
bind=True,
max_retries=0,
)
def purge_dataflow_runs(self, limit: int = 500):
"""Apply Dataflow evidence-retention policy without deleting run records."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
provider = _dataflow_run_worker()
if provider is None:
return {"purged": 0}
result = dict(provider.purge_expired(session, limit=limit))
session.commit()
return result
@celery.task(
name="govoplan.workflow.reconcile",
bind=True,
max_retries=0,
)
def reconcile_workflow_instances(self, limit: int = 50):
"""Resume asynchronous Workflow steps from durable provider state."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
provider = _workflow_runtime_worker()
if provider is None:
return {
"inspected": 0,
"advanced": 0,
"waiting": 0,
"failed": 0,
}
result = dict(provider.reconcile_pending(session, limit=limit))
session.commit()
return result
@celery.task(
name="govoplan.postbox.dispatch_routes",
bind=True,
max_retries=0,
)
def dispatch_postbox_routes(
self,
tenant_id: str | None = None,
limit: int = 50,
):
"""Deliver due Postbox vacancy escalations from durable route rows."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
provider = _postbox_routing_provider()
if provider is None:
return {
"selected": 0,
"delivered": 0,
"vacant": 0,
"rescheduled": 0,
"cancelled": 0,
"failed": 0,
"route_ids": [],
}
result = dict(
provider.dispatch_due_routes(
session,
tenant_id=tenant_id,
limit=limit,
)
)
session.commit()
return result
@celery.task(
name="govoplan.idm.expire_assignments",
bind=True,
max_retries=0,
)
def expire_idm_assignments(
self,
tenant_id: str | None = None,
limit: int = 100,
):
"""Emit idempotent lifecycle events for elapsed IDM assignments."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
provider = _idm_assignment_lifecycle()
if provider is None:
return {"selected": 0, "expired": 0, "assignment_ids": []}
result = dict(
provider.process_expired(
session,
tenant_id=tenant_id,
limit=limit,
)
)
session.commit()
return result
@celery.task(
name="govoplan.events.dispatch_outbox",
bind=True,
max_retries=0,
)
def dispatch_platform_events(self, limit: int = 100):
"""Deliver committed platform events through persistent consumer ledgers."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
registry = _platform_registry()
outbox = _platform_event_outbox(registry)
if outbox is None:
return {
"selected": 0,
"delivered": 0,
"retrying": 0,
"quarantined": 0,
"dispatched": 0,
"observer_failed": 0,
}
dataflow_dispatcher = _dataflow_trigger_dispatcher(registry)
workflow_dispatcher = _workflow_trigger_dispatcher(registry)
consumers: list[DurableEventConsumer] = []
if dataflow_dispatcher is not None:
def deliver_to_dataflow(
event: PlatformEvent,
_delivery_key: str,
) -> None:
dataflow_dispatcher.ingest_event(
session,
event=event,
)
consumers.append(
DurableEventConsumer(
consumer_id="dataflow.event-triggers.v1",
event_types=frozenset({"*"}),
classifications=frozenset({"public", "internal"}),
handler=deliver_to_dataflow,
)
)
if workflow_dispatcher is not None:
def deliver_to_workflow(
event: PlatformEvent,
_delivery_key: str,
) -> None:
workflow_dispatcher.ingest_event(
session,
event=event,
)
consumers.append(
DurableEventConsumer(
consumer_id="workflow.event-triggers.v1",
event_types=frozenset({"*"}),
classifications=frozenset({"public", "internal"}),
handler=deliver_to_workflow,
)
)
result = dict(
outbox.dispatch_pending(
session,
consumers=tuple(consumers),
observer=publish_platform_event,
limit=limit,
)
)
session.commit()
return result
@celery.task(
name="govoplan.events.purge_outbox",
bind=True,
max_retries=0,
)
def purge_platform_events(self, limit: int = 500):
"""Remove old terminal event envelopes while retaining quarantine evidence."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
outbox = _platform_event_outbox()
if outbox is None:
return {"deleted": 0}
before = datetime.now(timezone.utc) - timedelta(
days=settings.platform_event_outbox_terminal_retention_days
)
result = dict(
outbox.purge_terminal(
session,
before=before,
limit=limit,
)
)
session.commit()
return result
+199
View File
@@ -0,0 +1,199 @@
from __future__ import annotations
import argparse
from importlib.metadata import PackageNotFoundError, version
import signal
import subprocess
import sys
import threading
import time
from typing import Sequence
from govoplan_core.core.runtime_coordination import (
LeaseClaim,
acquire_lease,
release_lease,
renew_lease,
runtime_identity,
)
from govoplan_core.db.session import configure_database, get_database
from govoplan_core.settings import settings
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Run one process while holding a database-fenced deployment lease."
)
parser.add_argument(
"--resource", required=True, help="Stable cluster-wide lease key."
)
parser.add_argument("--ttl-seconds", type=int, default=60)
parser.add_argument("--renew-seconds", type=int, default=15)
parser.add_argument("--wait-seconds", type=int, default=0)
parser.add_argument("command", nargs=argparse.REMAINDER)
return parser
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
command = list(args.command)
if command and command[0] == "--":
command.pop(0)
if not command:
raise SystemExit("fenced-run requires a command after --")
if args.ttl_seconds < 10:
raise SystemExit("--ttl-seconds must be at least 10")
if args.renew_seconds < 2 or args.renew_seconds * 2 >= args.ttl_seconds:
raise SystemExit("--renew-seconds must be less than half the lease TTL")
configure_database(settings.database_url)
identity = runtime_identity(
settings,
software_version=_core_version(),
role=str(settings.runtime_role or "deployment"),
)
claim = _wait_for_lease(
resource=args.resource,
identity=identity,
ttl_seconds=args.ttl_seconds,
wait_seconds=max(0, args.wait_seconds),
)
if claim is None:
print(
f"lease unavailable: {args.resource}",
file=sys.stderr,
)
return 75
process = subprocess.Popen(command) # noqa: S603 - argv is an operator-owned container command
stop = threading.Event()
fence_lost = threading.Event()
renewer = threading.Thread(
target=_renew_loop,
kwargs={
"claim": claim,
"ttl_seconds": args.ttl_seconds,
"renew_seconds": args.renew_seconds,
"stop": stop,
"fence_lost": fence_lost,
"process": process,
},
daemon=True,
name=f"govoplan-fence:{args.resource}",
)
renewer.start()
previous_handlers = _forward_signals(process)
try:
return_code = process.wait()
finally:
stop.set()
renewer.join(timeout=args.renew_seconds + 2)
_restore_signals(previous_handlers)
_release(claim)
if fence_lost.is_set():
return 74
return int(return_code)
def _wait_for_lease(
*,
resource: str,
identity,
ttl_seconds: int,
wait_seconds: int,
) -> LeaseClaim | None:
deadline = time.monotonic() + wait_seconds
while True:
with get_database().SessionLocal() as session:
claim = acquire_lease(
session,
installation_id=identity.installation_id,
resource_key=resource,
holder_node_id=identity.node_id,
holder_incarnation=identity.incarnation,
ttl_seconds=ttl_seconds,
metadata={"role": identity.role},
)
session.commit()
if claim is not None or time.monotonic() >= deadline:
return claim
time.sleep(min(2, max(0.1, deadline - time.monotonic())))
def _renew_loop(
*,
claim: LeaseClaim,
ttl_seconds: int,
renew_seconds: int,
stop: threading.Event,
fence_lost: threading.Event,
process: subprocess.Popen[bytes],
) -> None:
active_claim = claim
while not stop.wait(renew_seconds):
try:
with get_database().SessionLocal() as session:
active_claim = renew_lease(
session,
active_claim,
ttl_seconds=ttl_seconds,
)
session.commit()
except Exception: # noqa: BLE001 - any renewal failure loses authority
fence_lost.set()
_terminate_process(process)
return
def _terminate_process(
process: subprocess.Popen[bytes],
*,
timeout_seconds: float = 5.0,
) -> None:
if process.poll() is not None:
return
process.terminate()
try:
process.wait(timeout=timeout_seconds)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=timeout_seconds)
def _release(claim: LeaseClaim) -> None:
try:
with get_database().SessionLocal() as session:
release_lease(session, claim)
session.commit()
except Exception: # noqa: BLE001 - authority is already lost; release is best effort
return
def _forward_signals(
process: subprocess.Popen[bytes],
) -> dict[int, signal.Handlers]:
previous: dict[int, signal.Handlers] = {}
def forward(signum, _frame) -> None:
if process.poll() is None:
process.send_signal(signum)
for signum in (signal.SIGTERM, signal.SIGINT):
previous[signum] = signal.getsignal(signum)
signal.signal(signum, forward)
return previous
def _restore_signals(previous: dict[int, signal.Handlers]) -> None:
for signum, handler in previous.items():
signal.signal(signum, handler)
def _core_version() -> str:
try:
return version("govoplan-core")
except PackageNotFoundError:
return "development"
if __name__ == "__main__":
raise SystemExit(main())
+32 -19
View File
@@ -12,6 +12,7 @@ from govoplan_core.db.migrations import (
migrate_database,
run_registered_module_migration_tasks,
)
from govoplan_core.db.migration_lock import deployment_migration_lock
from govoplan_core.db.session import configure_database, get_database
from govoplan_core.settings import settings
@@ -28,6 +29,12 @@ def main() -> None:
parser.add_argument("--enabled-module", action="append", default=[], help="Target enabled module id used to discover module migrations; may be repeated.")
parser.add_argument("--migration-module", action="append", default=[], help="Module id whose migration heads should be upgraded in this order before final heads.")
parser.add_argument("--migration-task-record-output", type=Path, help="Write executed module migration task records to this JSON file.")
parser.add_argument(
"--migration-lock-timeout-seconds",
type=float,
default=900.0,
help="Maximum wait for the deployment-wide PostgreSQL advisory lock.",
)
parser.add_argument("--with-dev-data", action="store_true", help="Create default tenant/user/roles and a development API key")
parser.add_argument("--dev-api-key", default=settings.dev_bootstrap_api_key, help="Development API key secret to create")
args = parser.parse_args()
@@ -37,26 +44,32 @@ def main() -> None:
migration_order = tuple(args.migration_module) if args.migration_module else None
task_records: list[dict[str, object]] = []
try:
_run_migration_tasks(
task_records,
database_url=args.database_url,
enabled_modules=enabled_modules,
migration_order=migration_order,
phases=PRE_MIGRATION_TASK_PHASES,
)
migration = migrate_database(
database_url=args.database_url,
enabled_modules=enabled_modules,
migration_module_order=migration_order,
with deployment_migration_lock(
args.database_url,
installation_id=settings.installation_id,
migration_track=args.migration_track,
)
_run_migration_tasks(
task_records,
database_url=args.database_url,
enabled_modules=enabled_modules,
migration_order=migration_order,
phases=POST_MIGRATION_TASK_PHASES,
)
timeout_seconds=args.migration_lock_timeout_seconds,
):
_run_migration_tasks(
task_records,
database_url=args.database_url,
enabled_modules=enabled_modules,
migration_order=migration_order,
phases=PRE_MIGRATION_TASK_PHASES,
)
migration = migrate_database(
database_url=args.database_url,
enabled_modules=enabled_modules,
migration_module_order=migration_order,
migration_track=args.migration_track,
)
_run_migration_tasks(
task_records,
database_url=args.database_url,
enabled_modules=enabled_modules,
migration_order=migration_order,
phases=POST_MIGRATION_TASK_PHASES,
)
finally:
if args.migration_task_record_output:
args.migration_task_record_output.parent.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,61 @@
from __future__ import annotations
import argparse
import sys
import time
from govoplan_core.db.migrations import (
configured_migration_heads,
database_migration_heads,
)
from govoplan_core.settings import settings
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Wait until the database is at this release's migration heads."
)
parser.add_argument("--database-url", default=settings.database_url)
parser.add_argument(
"--migration-track",
default=settings.migration_track,
choices=("release", "dev"),
)
parser.add_argument("--timeout-seconds", type=float, default=900.0)
parser.add_argument("--poll-seconds", type=float, default=2.0)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.timeout_seconds < 0 or args.poll_seconds <= 0:
raise SystemExit("timeouts must be non-negative and polling must be positive")
expected = configured_migration_heads(
args.database_url,
migration_track=args.migration_track,
)
deadline = time.monotonic() + args.timeout_seconds
last_error = ""
while True:
try:
actual = database_migration_heads(args.database_url)
if actual == expected:
print("Database migration heads are ready: " + ",".join(actual))
return 0
last_error = (
f"database heads={','.join(actual) or '<none>'}; "
f"expected={','.join(expected) or '<none>'}"
)
except Exception as exc: # noqa: BLE001 - connection may become ready later
last_error = f"{type(exc).__name__}: {exc}"
if time.monotonic() >= deadline:
print(
"Database did not reach configured migration heads: " + last_error,
file=sys.stderr,
)
return 75
time.sleep(min(args.poll_seconds, max(0.05, deadline - time.monotonic())))
if __name__ == "__main__":
raise SystemExit(main())
+16
View File
@@ -6,6 +6,7 @@ from datetime import datetime
from typing import Literal, Protocol, cast, runtime_checkable
from govoplan_core.core.modules import AccessDecision
from govoplan_core.core.institutional import GovernedContextEnvelope
ACCESS_MODULE_ID = "access"
@@ -28,6 +29,9 @@ CAPABILITY_AUDIT_RECORDER = "audit.recorder"
CAPABILITY_AUDIT_RETENTION = "audit.retention"
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER = "auth.apiPrincipalProvider"
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER = (
"auth.automationPrincipalProvider"
)
CAPABILITY_AUTH_PRINCIPAL_RESOLVER = "auth.principalResolver"
CAPABILITY_AUTH_PERMISSION_EVALUATOR = "auth.permissionEvaluator"
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER = "auth.tenantContextSwitcher"
@@ -48,6 +52,7 @@ ACCESS_CAPABILITY_NAMES = frozenset(
CAPABILITY_AUDIT_RECORDER,
CAPABILITY_AUDIT_RETENTION,
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
@@ -57,12 +62,19 @@ ACCESS_CAPABILITY_NAMES = frozenset(
AUTH_CAPABILITY_NAMES = frozenset(
{
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
}
)
DEFAULT_CAPABILITY_PROVIDERS: Mapping[str, str] = {
CAPABILITY_AUTH_PRINCIPAL_RESOLVER: ACCESS_MODULE_ID,
CAPABILITY_AUTH_PERMISSION_EVALUATOR: ACCESS_MODULE_ID,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER: ACCESS_MODULE_ID,
}
AuthMethod = Literal["session", "api_key", "service_account"]
AccessSubjectKind = Literal[
"identity",
@@ -114,6 +126,7 @@ class PrincipalRef:
api_key_id: str | None = None
session_id: str | None = None
service_account_id: str | None = None
acting_assignment_id: str | None = None
acting_for_account_id: str | None = None
email: str | None = None
display_name: str | None = None
@@ -133,6 +146,7 @@ class PrincipalRef:
"api_key_id": self.api_key_id,
"session_id": self.session_id,
"service_account_id": self.service_account_id,
"acting_assignment_id": self.acting_assignment_id,
"acting_for_account_id": self.acting_for_account_id,
"email": self.email,
"display_name": self.display_name,
@@ -154,6 +168,7 @@ class PrincipalRef:
api_key_id=_optional_str(value.get("api_key_id")),
session_id=_optional_str(value.get("session_id")),
service_account_id=_optional_str(value.get("service_account_id")),
acting_assignment_id=_optional_str(value.get("acting_assignment_id")),
acting_for_account_id=_optional_str(value.get("acting_for_account_id")),
email=_optional_str(value.get("email")),
display_name=_optional_str(value.get("display_name")),
@@ -363,6 +378,7 @@ class AuditEvent:
occurred_at: datetime | None = None
correlation_id: str | None = None
causation_id: str | None = None
institutional_context: GovernedContextEnvelope | None = None
details: Mapping[str, object] = field(default_factory=dict)
+207
View File
@@ -0,0 +1,207 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
CAPABILITY_APPROVAL_REQUESTS = "approvals.requests"
class ApprovalCapabilityError(ValueError):
"""Stable error raised by Approval capability implementations."""
@dataclass(frozen=True, slots=True)
class ApprovalActorSelector:
kind: str
value: str
label: str | None = None
@dataclass(frozen=True, slots=True)
class ApprovalStepDefinition:
key: str
label: str
selectors: tuple[ApprovalActorSelector, ...]
required_approvals: int = 1
rejection_policy: str = "fail_fast"
due_at: datetime | None = None
signature_required: bool = False
forbidden_evidence_roles: tuple[str, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ApprovalRequestCreateCommand:
title: str
subject_module: str
subject_type: str
subject_id: str
subject_version: str | None
subject_digest: str
steps: tuple[ApprovalStepDefinition, ...]
description: str | None = None
separation_of_duties: bool = True
unique_actors_across_steps: bool = False
expires_at: datetime | None = None
policy_refs: tuple[str, ...] = ()
evidence_actors: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
template_id: str | None = None
template_revision: int | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ApprovalTemplateCreateCommand:
key: str
title: str
steps: tuple[ApprovalStepDefinition, ...]
description: str | None = None
separation_of_duties: bool = True
unique_actors_across_steps: bool = False
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ApprovalTemplateRef:
id: str
key: str
revision: int
state: str
content_sha256: str
@dataclass(frozen=True, slots=True)
class ApprovalDecisionCommand:
outcome: str
reason: str
expected_revision: int
idempotency_key: str
delegated_for_account_id: str | None = None
signature_ref: Mapping[str, object] | None = None
@dataclass(frozen=True, slots=True)
class ApprovalRequestRef:
id: str
revision: int
state: str
current_step_key: str | None = None
@dataclass(frozen=True, slots=True)
class ApprovalDecisionReceipt:
request_id: str
revision: int
state: str
step_key: str
outcome: str
actor_id: str
recorded_at: datetime
receipt_sha256: str
authority_provenance: Mapping[str, object] = field(default_factory=dict)
replayed: bool = False
@dataclass(frozen=True, slots=True)
class ApprovalCheck:
request_id: str
revision: int
state: str
approved: bool
subject_module: str
subject_type: str
subject_id: str
subject_version: str | None
subject_digest: str
completed_at: datetime | None = None
@runtime_checkable
class ApprovalRequestProvider(Protocol):
def create_template(
self,
session: object,
principal: object,
*,
command: ApprovalTemplateCreateCommand,
idempotency_key: str,
) -> ApprovalTemplateRef: ...
def revise_template(
self,
session: object,
principal: object,
*,
template_id: str,
command: ApprovalTemplateCreateCommand,
expected_revision: int,
idempotency_key: str,
) -> ApprovalTemplateRef: ...
def publish_template(
self,
session: object,
principal: object,
*,
template_id: str,
expected_revision: int,
idempotency_key: str,
) -> ApprovalTemplateRef: ...
def create_request(
self,
session: object,
principal: object,
*,
command: ApprovalRequestCreateCommand,
idempotency_key: str,
) -> ApprovalRequestRef: ...
def get_request(
self,
session: object,
principal: object,
*,
request_id: str,
) -> Mapping[str, object] | None: ...
def decide(
self,
session: object,
principal: object,
*,
request_id: str,
command: ApprovalDecisionCommand,
) -> ApprovalDecisionReceipt: ...
def check_approved(
self,
session: object,
principal: object,
*,
request_id: str,
subject_module: str,
subject_type: str,
subject_id: str,
subject_version: str | None,
subject_digest: str,
) -> ApprovalCheck: ...
__all__ = [
"ApprovalActorSelector",
"ApprovalCapabilityError",
"ApprovalCheck",
"ApprovalDecisionCommand",
"ApprovalDecisionReceipt",
"ApprovalRequestCreateCommand",
"ApprovalRequestProvider",
"ApprovalRequestRef",
"ApprovalStepDefinition",
"ApprovalTemplateCreateCommand",
"ApprovalTemplateRef",
"CAPABILITY_APPROVAL_REQUESTS",
]
+395
View File
@@ -0,0 +1,395 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.access import (
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
)
from govoplan_core.core.institutional import GovernedContextEnvelope
AutomationInvocationKind = Literal[
"manual",
"api",
"schedule",
"event",
"workflow",
"dependency",
"retry",
"backfill",
]
AutomationSubjectKind = Literal["delegated_user", "service_account"]
AUTOMATION_PRINCIPAL_CONTRACT_VERSION = "1"
ACTION_EFFECT_CONTRACT_VERSION = "1"
ActionRiskLevel = Literal["low", "moderate", "high", "critical"]
ActionReversibility = Literal[
"reversible",
"compensatable",
"corrective_only",
"irreversible",
]
ActionExecutionState = Literal[
"pending",
"running",
"completed",
"blocked",
"retryable",
"quarantined",
"manual_required",
"compensation_required",
]
EffectOperation = Literal[
"created",
"changed",
"deleted",
"sent",
"notified",
"locked",
"retained",
"external",
]
@dataclass(frozen=True, slots=True)
class EffectDefinition:
effect_key: str
owner_module: str
operation: EffectOperation
description: str
resource_types: tuple[str, ...] = ()
visibility_classification: str = "internal"
audit_event_types: tuple[str, ...] = ()
compensation_hint: str | None = None
contract_version: str = ACTION_EFFECT_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract_version(self.contract_version)
_require_text(self.effect_key, "Effect key")
_require_text(self.owner_module, "Effect owner module")
_require_text(self.description, "Effect description")
@dataclass(frozen=True, slots=True)
class ActionDefinition:
action_key: str
owner_module: str
description: str
input_schema_ref: str
required_scopes: tuple[str, ...] = ()
required_capabilities: tuple[str, ...] = ()
policy_checks: tuple[str, ...] = ()
risk_level: ActionRiskLevel = "moderate"
reversibility: ActionReversibility = "corrective_only"
expected_effect_keys: tuple[str, ...] = ()
idempotency_strategy: str = "caller_supplied"
audit_event_types: tuple[str, ...] = ()
preview_required: bool = True
contract_version: str = ACTION_EFFECT_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract_version(self.contract_version)
_require_text(self.action_key, "Action key")
_require_text(self.owner_module, "Action owner module")
_require_text(self.description, "Action description")
_require_text(self.input_schema_ref, "Action input schema reference")
_require_text(self.idempotency_strategy, "Action idempotency strategy")
if any(not value.strip() for value in self.required_scopes):
raise ValueError("Action scopes must not be empty")
if any(not value.strip() for value in self.required_capabilities):
raise ValueError("Action capabilities must not be empty")
if any(not value.strip() for value in self.expected_effect_keys):
raise ValueError("Expected effect keys must not be empty")
@dataclass(frozen=True, slots=True)
class ActionExecutionRequest:
tenant_id: str
action_key: str
input: Mapping[str, object]
idempotency_key: str
invocation: AutomationInvocation
institutional_context: GovernedContextEnvelope | None = None
actor_ref: str | None = None
preview_ref: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
contract_version: str = ACTION_EFFECT_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract_version(self.contract_version)
_require_text(self.tenant_id, "Action tenant id")
_require_text(self.action_key, "Action key")
_require_text(self.idempotency_key, "Action idempotency key")
@dataclass(frozen=True, slots=True)
class EffectPreview:
effect_key: str
summary: str
resource_refs: tuple[str, ...] = ()
external_system_refs: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class ActionPreview:
action_key: str
allowed: bool
summary: str
risk_level: ActionRiskLevel
reversibility: ActionReversibility
effects: tuple[EffectPreview, ...] = ()
blockers: tuple[str, ...] = ()
policy_provenance: tuple[Mapping[str, object], ...] = ()
preview_ref: str | None = None
@dataclass(frozen=True, slots=True)
class ObservedEffect:
effect_key: str
operation: EffectOperation
resource_ref: str | None = None
external_system_ref: str | None = None
audit_event_ref: str | None = None
summary: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ActionExecutionResult:
state: ActionExecutionState
output: Mapping[str, object] = field(default_factory=dict)
observed_effects: tuple[ObservedEffect, ...] = ()
error: str | None = None
retry_after: datetime | None = None
manual_instructions: str | None = None
compensation_action_key: str | None = None
audit_event_refs: tuple[str, ...] = ()
@runtime_checkable
class ActionEffectProvider(Protocol):
def action_definitions(self) -> tuple[ActionDefinition, ...]: ...
def effect_definitions(self) -> tuple[EffectDefinition, ...]: ...
def preview_action(
self,
session: object,
principal: object,
*,
request: ActionExecutionRequest,
) -> ActionPreview: ...
def execute_action(
self,
session: object,
principal: object,
*,
request: ActionExecutionRequest,
) -> ActionExecutionResult: ...
@dataclass(frozen=True, slots=True)
class AutomationInvocation:
kind: AutomationInvocationKind = "manual"
trigger_ref: str | None = None
delivery_ref: str | None = None
event_id: str | None = None
event_type: str | None = None
correlation_id: str | None = None
causation_id: str | None = None
scheduled_for: datetime | None = None
requested_by: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class AutomationPrincipalRequest:
tenant_id: str
authorization_ref: str
grant_scopes: tuple[str, ...]
account_id: str | None = None
membership_id: str | None = None
service_account_id: str | None = None
subject_kind: AutomationSubjectKind = "delegated_user"
context: Mapping[str, object] = field(default_factory=dict)
contract_version: str = AUTOMATION_PRINCIPAL_CONTRACT_VERSION
def __post_init__(self) -> None:
if (
self.contract_version
!= AUTOMATION_PRINCIPAL_CONTRACT_VERSION
):
raise ValueError(
"Unsupported automation-principal contract version"
)
if not self.tenant_id.strip():
raise ValueError("Automation tenant id is required")
if not self.authorization_ref.strip():
raise ValueError(
"Automation authorization artifact reference is required"
)
if self.subject_kind == "delegated_user":
if (
not self.account_id
or not self.membership_id
or self.service_account_id is not None
):
raise ValueError(
"Delegated-user automation requires account and "
"membership references only"
)
elif (
not self.service_account_id
or self.account_id is not None
or self.membership_id is not None
):
raise ValueError(
"Service-account automation requires only a service-account "
"reference"
)
if any(
not scope.strip()
for scope in self.grant_scopes
):
raise ValueError("Automation grant scopes must not be empty")
@classmethod
def delegated_user(
cls,
*,
tenant_id: str,
account_id: str,
membership_id: str,
authorization_ref: str,
grant_scopes: tuple[str, ...],
context: Mapping[str, object] | None = None,
) -> AutomationPrincipalRequest:
return cls(
tenant_id=tenant_id,
account_id=account_id,
membership_id=membership_id,
authorization_ref=authorization_ref,
grant_scopes=grant_scopes,
context=context or {},
)
@classmethod
def service_account(
cls,
*,
tenant_id: str,
service_account_id: str,
authorization_ref: str,
grant_scopes: tuple[str, ...],
context: Mapping[str, object] | None = None,
) -> AutomationPrincipalRequest:
return cls(
tenant_id=tenant_id,
service_account_id=service_account_id,
subject_kind="service_account",
authorization_ref=authorization_ref,
grant_scopes=grant_scopes,
context=context or {},
)
@dataclass(frozen=True, slots=True)
class AutomationPrincipalResolution:
allowed: bool
principal: object | None = None
reason: str | None = None
granted_scopes: tuple[str, ...] = ()
missing_scopes: tuple[str, ...] = ()
provenance: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class AutomationPrincipalProvider(Protocol):
def resolve_automation_principal(
self,
session: object,
*,
request: AutomationPrincipalRequest,
) -> AutomationPrincipalResolution:
...
def automation_principal_provider(
registry: object | None,
) -> AutomationPrincipalProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
)
):
return None
capability = registry.capability(
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
)
return (
capability
if isinstance(capability, AutomationPrincipalProvider)
else None
)
def action_effect_provider(
registry: object | None,
capability_name: str,
) -> ActionEffectProvider | None:
name = capability_name.strip()
if (
not name
or registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(name)
):
return None
capability = registry.capability(name)
return (
capability
if isinstance(capability, ActionEffectProvider)
else None
)
def _validate_contract_version(value: str) -> None:
if value != ACTION_EFFECT_CONTRACT_VERSION:
raise ValueError("Unsupported action/effect contract version")
def _require_text(value: str, label: str) -> None:
if not value.strip():
raise ValueError(f"{label} is required")
__all__ = [
"ACTION_EFFECT_CONTRACT_VERSION",
"CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER",
"AUTOMATION_PRINCIPAL_CONTRACT_VERSION",
"ActionDefinition",
"ActionEffectProvider",
"ActionExecutionRequest",
"ActionExecutionResult",
"ActionExecutionState",
"AutomationInvocation",
"AutomationInvocationKind",
"AutomationPrincipalProvider",
"AutomationPrincipalRequest",
"AutomationPrincipalResolution",
"AutomationSubjectKind",
"ActionPreview",
"ActionReversibility",
"ActionRiskLevel",
"EffectDefinition",
"EffectOperation",
"EffectPreview",
"ObservedEffect",
"action_effect_provider",
"automation_principal_provider",
]
+96
View File
@@ -8,6 +8,7 @@ from typing import Protocol, runtime_checkable
CAPABILITY_CALENDAR_SCHEDULING = "calendar.scheduling"
CAPABILITY_CALENDAR_OUTBOX = "calendar.outbox"
CAPABILITY_CALENDAR_INVITATIONS = "calendar.invitations"
CALENDAR_AVAILABILITY_READ_SCOPE = "calendar:availability:read"
CALENDAR_EVENT_WRITE_SCOPE = "calendar:event:write"
@@ -43,6 +44,52 @@ class CalendarEventRef:
outbox_operation_id: str | None = None
@dataclass(frozen=True, slots=True)
class CalendarInvitationAttendeeRequest:
address: str
name: str | None = None
role: str = "REQ-PARTICIPANT"
participation_status: str = "NEEDS-ACTION"
rsvp: bool = True
@dataclass(frozen=True, slots=True)
class CalendarInvitationRequest:
correlation_id: str
source_module: str
source_resource_type: str
source_resource_id: str | None
summary: str
start_at: datetime
attendees: tuple[CalendarInvitationAttendeeRequest, ...]
calendar_id: str | None = None
description: str | None = None
location: str | None = None
end_at: datetime | None = None
timezone: str | None = None
organizer: Mapping[str, object] | None = None
classification: str = "PUBLIC"
categories: tuple[str, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class CalendarInvitationRef:
event_id: str
calendar_id: str
uid: str
correlation_id: str
source_module: str
source_resource_type: str
source_resource_id: str | None
attendees: tuple[Mapping[str, object], ...] = ()
external_state: str = "local"
outbox_operation_id: str | None = None
reply_ingress: str = "capability"
recurrence_supported: bool = False
degraded_reasons: tuple[str, ...] = ()
@runtime_checkable
class CalendarSchedulingProvider(Protocol):
def list_freebusy(
@@ -80,6 +127,44 @@ class CalendarOutboxProvider(Protocol):
) -> Mapping[str, object]:
...
@runtime_checkable
class CalendarInvitationProvider(Protocol):
"""Correlation-aware invitation boundary for Campaign and Mail adapters."""
def upsert_invitation(
self,
session: object,
*,
tenant_id: str,
user_id: str | None,
request: CalendarInvitationRequest,
) -> CalendarInvitationRef:
...
def get_invitation(
self,
session: object,
*,
tenant_id: str,
correlation_id: str,
) -> CalendarInvitationRef | None:
...
def record_response(
self,
session: object,
*,
tenant_id: str,
attendee_address: str,
participation_status: str,
correlation_id: str | None = None,
uid: str | None = None,
responded_at: datetime | None = None,
evidence: Mapping[str, object] | None = None,
) -> CalendarInvitationRef:
...
def calendar_scheduling_provider(registry: object | None) -> CalendarSchedulingProvider | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
@@ -96,3 +181,14 @@ def calendar_outbox_provider(registry: object | None) -> CalendarOutboxProvider
return None
capability = registry.capability(CAPABILITY_CALENDAR_OUTBOX)
return capability if isinstance(capability, CalendarOutboxProvider) else None
def calendar_invitation_provider(
registry: object | None,
) -> CalendarInvitationProvider | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_CALENDAR_INVITATIONS):
return None
capability = registry.capability(CAPABILITY_CALENDAR_INVITATIONS)
return capability if isinstance(capability, CalendarInvitationProvider) else None
+5 -4
View File
@@ -6,7 +6,7 @@ from typing import Any, Iterable, Sequence
from sqlalchemy import BigInteger, DateTime, Index, Integer, JSON, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, Session, mapped_column
from govoplan_core.core.events import EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, publish_platform_event
from govoplan_core.core.events import EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, emit_platform_event
from govoplan_core.db.base import Base, utcnow
WATERMARK_PREFIX = "seq:"
@@ -96,13 +96,14 @@ def record_change(
payload=payload or {},
)
session.add(entry)
_publish_change_event(entry)
_publish_change_event(session, entry)
return entry
def _publish_change_event(entry: ChangeSequenceEntry) -> None:
def _publish_change_event(session: Session, entry: ChangeSequenceEntry) -> None:
event_type = _change_event_type(entry.module_id, entry.resource_type, entry.operation)
publish_platform_event(
emit_platform_event(
session,
PlatformEvent(
type=event_type,
module_id=entry.module_id,
+559
View File
@@ -0,0 +1,559 @@
from __future__ import annotations
import copy
import hashlib
import json
from dataclasses import dataclass, field
from typing import Any, Callable, Iterable, Mapping, Sequence
from sqlalchemy.orm import Session
class ConcurrencyError(RuntimeError):
"""Base class for mutation precondition and compare-and-set failures."""
class MissingPreconditionError(ConcurrencyError):
def __init__(self, *, resource_type: str, resource_id: str) -> None:
self.resource_type = resource_type
self.resource_id = resource_id
super().__init__("A strong If-Match precondition is required for this mutation.")
def as_dict(self) -> dict[str, Any]:
return {
"code": "precondition_required",
"resource": {
"type": self.resource_type,
"id": self.resource_id,
},
"retryable": True,
}
class RevisionConflictError(ConcurrencyError):
def __init__(
self,
*,
resource_type: str,
resource_id: str,
current_revision: int,
submitted_base_revision: int,
refresh_path: str | None = None,
conflicts: Sequence["MergeConflict"] = (),
merge_candidate: Any = None,
current_etag: str | None = None,
) -> None:
self.resource_type = resource_type
self.resource_id = resource_id
self.current_revision = int(current_revision)
self.submitted_base_revision = int(submitted_base_revision)
self.refresh_path = refresh_path
self.conflicts = tuple(conflicts)
self.merge_candidate = merge_candidate
self.current_etag = current_etag
super().__init__(
f"{resource_type} {resource_id} changed from revision "
f"{submitted_base_revision} to {current_revision}"
)
@property
def safe_merge_available(self) -> bool:
return self.merge_candidate is not None and not self.conflicts
def as_dict(
self,
*,
include_values: bool = False,
include_merge_candidate: bool = False,
) -> dict[str, Any]:
result: dict[str, Any] = {
"code": "revision_conflict",
"resource": {
"type": self.resource_type,
"id": self.resource_id,
},
"current_revision": self.current_revision,
"submitted_base_revision": self.submitted_base_revision,
"retryable": True,
"safe_merge_available": self.safe_merge_available,
"conflicts": [
conflict.as_dict(include_values=include_values)
for conflict in self.conflicts[:100]
],
}
if self.refresh_path:
result["refresh_path"] = self.refresh_path
if self.current_etag:
result["current_etag"] = self.current_etag
if include_merge_candidate and self.safe_merge_available:
result["merge_candidate"] = copy.deepcopy(self.merge_candidate)
return result
@dataclass(frozen=True, slots=True)
class MergeConflict:
path: str
kind: str
base_value: Any = None
local_value: Any = None
current_value: Any = None
def as_dict(self, *, include_values: bool = False) -> dict[str, Any]:
result: dict[str, Any] = {
"path": self.path or "/",
"kind": self.kind,
}
if include_values:
result.update(
{
"base_value": _bounded_json_value(self.base_value),
"local_value": _bounded_json_value(self.local_value),
"current_value": _bounded_json_value(self.current_value),
}
)
return result
@dataclass(slots=True)
class ThreeWayMergeResult:
value: Any
conflicts: list[MergeConflict] = field(default_factory=list)
applied_paths: list[str] = field(default_factory=list)
@property
def merged(self) -> bool:
return not self.conflicts
ProtectedPath = str | Callable[[str], bool]
def strong_resource_etag(
resource_type: str,
resource_id: str,
revision: int,
) -> str:
"""Return an opaque strong ETag for one mutable aggregate revision."""
normalized_revision = int(revision)
if normalized_revision < 1:
raise ValueError("Resource revisions must be positive integers")
digest = hashlib.sha256(
"\x00".join(
(
"govoplan-strong-revision-v1",
str(resource_type),
str(resource_id),
str(normalized_revision),
)
).encode("utf-8")
).hexdigest()
return f'"sha256-{digest}"'
def if_match_matches(header_value: str | None, expected_etag: str) -> bool:
"""Apply strong comparison semantics to an If-Match header."""
if not header_value:
return False
for raw_candidate in header_value.split(","):
candidate = raw_candidate.strip()
if candidate == "*":
return True
if candidate.startswith("W/"):
continue
if candidate == expected_etag:
return True
return False
def assert_revision_precondition(
if_match: str | None,
*,
resource_type: str,
resource_id: str,
submitted_base_revision: int,
) -> None:
if not if_match:
raise MissingPreconditionError(
resource_type=resource_type,
resource_id=resource_id,
)
submitted_etag = strong_resource_etag(
resource_type,
resource_id,
submitted_base_revision,
)
if not if_match_matches(if_match, submitted_etag):
raise ConcurrencyError(
"If-Match does not identify the submitted base revision."
)
def claim_revision(
session: Session,
*,
model: type[Any],
filters: Iterable[Any],
revision_attribute: str,
expected_revision: int,
resource_type: str,
resource_id: str,
refresh_path: str | None = None,
) -> int:
"""Atomically claim the next revision in the caller's transaction."""
revision_column = getattr(model, revision_attribute)
expected = int(expected_revision)
normalized_filters = tuple(filters)
query = session.query(model).filter(*normalized_filters)
updated = query.filter(revision_column == expected).update(
{revision_column: revision_column + 1},
synchronize_session=False,
)
if updated == 1:
session.flush()
return expected + 1
current = (
session.query(revision_column)
.filter(*normalized_filters)
.scalar()
)
if current is None:
raise LookupError(f"{resource_type} {resource_id} was not found")
raise RevisionConflictError(
resource_type=resource_type,
resource_id=resource_id,
current_revision=int(current),
submitted_base_revision=expected,
refresh_path=refresh_path,
current_etag=strong_resource_etag(
resource_type,
resource_id,
int(current),
),
)
def three_way_merge(
base: Any,
local: Any,
current: Any,
*,
protected_paths: Sequence[ProtectedPath] = (),
stable_id_fields: Sequence[str] = ("id",),
) -> ThreeWayMergeResult:
"""Conservatively merge local changes onto a concurrently changed value."""
return _merge_value(
copy.deepcopy(base),
copy.deepcopy(local),
copy.deepcopy(current),
path="",
protected_paths=tuple(protected_paths),
stable_id_fields=tuple(stable_id_fields),
)
def _merge_value(
base: Any,
local: Any,
current: Any,
*,
path: str,
protected_paths: Sequence[ProtectedPath],
stable_id_fields: Sequence[str],
) -> ThreeWayMergeResult:
local_changed = local != base
current_changed = current != base
if not local_changed:
return ThreeWayMergeResult(value=current)
if local == current:
return ThreeWayMergeResult(value=current)
if _is_protected(path, protected_paths):
return _conflict(path, "protected_path", base, local, current)
if not current_changed:
return ThreeWayMergeResult(
value=local,
applied_paths=[path or "/"],
)
if isinstance(base, Mapping) and isinstance(local, Mapping) and isinstance(current, Mapping):
return _merge_mapping(
base,
local,
current,
path=path,
protected_paths=protected_paths,
stable_id_fields=stable_id_fields,
)
if isinstance(base, list) and isinstance(local, list) and isinstance(current, list):
return _merge_list(
base,
local,
current,
path=path,
protected_paths=protected_paths,
stable_id_fields=stable_id_fields,
)
return _conflict(path, "same_path_changed", base, local, current)
def _merge_mapping(
base: Mapping[str, Any],
local: Mapping[str, Any],
current: Mapping[str, Any],
*,
path: str,
protected_paths: Sequence[ProtectedPath],
stable_id_fields: Sequence[str],
) -> ThreeWayMergeResult:
missing = object()
result: dict[str, Any] = {}
conflicts: list[MergeConflict] = []
applied_paths: list[str] = []
keys = list(dict.fromkeys((*current.keys(), *local.keys(), *base.keys())))
for key in keys:
child_path = _join_path(path, str(key))
base_value = base.get(key, missing)
local_value = local.get(key, missing)
current_value = current.get(key, missing)
merged = _merge_presence(
base_value,
local_value,
current_value,
missing=missing,
path=child_path,
protected_paths=protected_paths,
stable_id_fields=stable_id_fields,
)
conflicts.extend(merged.conflicts)
applied_paths.extend(merged.applied_paths)
if merged.value is not missing:
result[str(key)] = merged.value
return ThreeWayMergeResult(
value=result,
conflicts=conflicts,
applied_paths=applied_paths,
)
def _merge_presence(
base: Any,
local: Any,
current: Any,
*,
missing: object,
path: str,
protected_paths: Sequence[ProtectedPath],
stable_id_fields: Sequence[str],
) -> ThreeWayMergeResult:
if local is missing and current is missing:
return ThreeWayMergeResult(value=missing)
if base is missing:
if local is missing:
return ThreeWayMergeResult(value=current)
if current is missing:
if _is_protected(path, protected_paths):
return _conflict(path, "protected_path", None, local, None)
return ThreeWayMergeResult(value=local, applied_paths=[path])
if local == current:
return ThreeWayMergeResult(value=current)
return _conflict(path, "concurrent_add", None, local, current)
if local is missing:
if current == base:
if _is_protected(path, protected_paths):
return _conflict(path, "protected_path", base, None, current)
return ThreeWayMergeResult(value=missing, applied_paths=[path])
return _conflict(path, "delete_vs_edit", base, None, current)
if current is missing:
if local == base:
return ThreeWayMergeResult(value=missing)
return _conflict(path, "edit_vs_delete", base, local, None)
return _merge_value(
base,
local,
current,
path=path,
protected_paths=protected_paths,
stable_id_fields=stable_id_fields,
)
def _merge_list(
base: list[Any],
local: list[Any],
current: list[Any],
*,
path: str,
protected_paths: Sequence[ProtectedPath],
stable_id_fields: Sequence[str],
) -> ThreeWayMergeResult:
identity_field = _stable_identity_field(
(base, local, current),
stable_id_fields,
)
if identity_field is None:
return _conflict(path, "unkeyed_collection", base, local, current)
base_by_id = {str(item[identity_field]): item for item in base}
local_by_id = {str(item[identity_field]): item for item in local}
current_by_id = {str(item[identity_field]): item for item in current}
base_order = list(base_by_id)
local_order = list(local_by_id)
current_order = list(current_by_id)
local_reordered = _common_order(local_order, base_order) != _common_order(
base_order,
local_order,
)
current_reordered = _common_order(current_order, base_order) != _common_order(
base_order,
current_order,
)
if local_reordered and current_reordered and local_order != current_order:
return _conflict(path, "collection_reorder", base_order, local_order, current_order)
result_by_id: dict[str, Any] = {}
conflicts: list[MergeConflict] = []
applied_paths: list[str] = []
identities = list(dict.fromkeys((*current_order, *local_order, *base_order)))
missing = object()
for identity in identities:
item_path = _join_path(path, f"{identity_field}={identity}")
merged = _merge_presence(
base_by_id.get(identity, missing),
local_by_id.get(identity, missing),
current_by_id.get(identity, missing),
missing=missing,
path=item_path,
protected_paths=protected_paths,
stable_id_fields=stable_id_fields,
)
conflicts.extend(merged.conflicts)
applied_paths.extend(merged.applied_paths)
if merged.value is not missing:
result_by_id[identity] = merged.value
order_source = local_order if local_reordered and not current_reordered else current_order
merged_order = [identity for identity in order_source if identity in result_by_id]
for identity in identities:
if identity in result_by_id and identity not in merged_order:
merged_order.append(identity)
return ThreeWayMergeResult(
value=[result_by_id[identity] for identity in merged_order],
conflicts=conflicts,
applied_paths=applied_paths,
)
def _stable_identity_field(
values: Sequence[list[Any]],
candidates: Sequence[str],
) -> str | None:
all_items = [item for value in values for item in value]
if not all_items or not all(isinstance(item, Mapping) for item in all_items):
return None
for candidate in candidates:
valid = True
for value in values:
identities = [
str(item.get(candidate, "")).strip()
for item in value
if isinstance(item, Mapping)
]
if any(not identity for identity in identities) or len(identities) != len(
set(identities)
):
valid = False
break
if valid:
return candidate
return None
def _common_order(left: Sequence[str], right: Sequence[str]) -> list[str]:
right_set = set(right)
return [item for item in left if item in right_set]
def _is_protected(path: str, protected_paths: Sequence[ProtectedPath]) -> bool:
normalized = path or "/"
for protected in protected_paths:
if callable(protected):
if protected(normalized):
return True
continue
prefix = protected.rstrip("/") or "/"
if normalized == prefix or normalized.startswith(f"{prefix}/"):
return True
return False
def _join_path(parent: str, segment: str) -> str:
escaped = segment.replace("~", "~0").replace("/", "~1")
return f"{parent}/{escaped}" if parent else f"/{escaped}"
def _conflict(
path: str,
kind: str,
base: Any,
local: Any,
current: Any,
) -> ThreeWayMergeResult:
return ThreeWayMergeResult(
value=current,
conflicts=[
MergeConflict(
path=path or "/",
kind=kind,
base_value=base,
local_value=local,
current_value=current,
)
],
)
def _bounded_json_value(value: Any, *, depth: int = 0) -> Any:
if depth >= 4:
return {"summary": type(value).__name__}
if value is None or isinstance(value, (bool, int, float)):
return value
if isinstance(value, str):
return value[:500]
if isinstance(value, Mapping):
result = {
str(key)[:100]: _bounded_json_value(item, depth=depth + 1)
for key, item in list(value.items())[:20]
}
if len(value) > 20:
result["_truncated_items"] = len(value) - 20
return result
if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray)):
result = [
_bounded_json_value(item, depth=depth + 1)
for item in list(value)[:20]
]
if len(value) > 20:
result.append({"_truncated_items": len(value) - 20})
return result
try:
return json.loads(json.dumps(value))
except (TypeError, ValueError):
return {"summary": type(value).__name__}
__all__ = [
"ConcurrencyError",
"MergeConflict",
"MissingPreconditionError",
"RevisionConflictError",
"ThreeWayMergeResult",
"assert_revision_precondition",
"claim_revision",
"if_match_matches",
"strong_resource_etag",
"three_way_merge",
]
@@ -7,7 +7,8 @@ from datetime import UTC, datetime
from pathlib import Path
import json
import os
from typing import Any, Literal, Protocol, runtime_checkable
import re
from typing import Any, Literal, Protocol, cast, runtime_checkable
from govoplan_core.core.module_package_catalog import (
_canonical_catalog_bytes,
@@ -21,6 +22,12 @@ from govoplan_core.core.module_package_catalog import (
_load_private_key,
_parse_trusted_keys,
)
from govoplan_core.core.external_references import (
IntegrationMaturity,
SOURCE_AUTHORITY_MODES,
SourceAuthorityMode,
integration_maturity_rank,
)
from govoplan_core.security.http_fetch import fetch_http_text
@@ -28,6 +35,166 @@ CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider"
DiagnosticSeverity = Literal["blocker", "warning", "info"]
PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"]
ConfigurationPackageClass = Literal[
"reference",
"product",
"sector",
"deployment",
"integration",
]
ConfigurationPackageEvidenceKind = Literal[
"target_test",
"migration",
"upgrade",
"recovery",
"security",
"operations",
"accessibility",
"privacy",
"documentation",
]
CONFIGURATION_PACKAGE_CLASSES: tuple[ConfigurationPackageClass, ...] = (
"reference",
"product",
"sector",
"deployment",
"integration",
)
CONFIGURATION_PACKAGE_EVIDENCE_KINDS: tuple[
ConfigurationPackageEvidenceKind, ...
] = (
"target_test",
"migration",
"upgrade",
"recovery",
"security",
"operations",
"accessibility",
"privacy",
"documentation",
)
_SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
@dataclass(frozen=True, slots=True)
class ConfigurationPackageParent:
package_id: str
version: str
relation: Literal["derived_from", "specializes", "extends"] = "derived_from"
def __post_init__(self) -> None:
if not self.package_id.strip() or not self.version.strip():
raise ValueError("Configuration package parent id and version are required.")
if self.relation not in {"derived_from", "specializes", "extends"}:
raise ValueError(
f"Unsupported configuration package parent relation: {self.relation!r}."
)
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationPackageParent":
relation = _optional_str(value, "relation") or "derived_from"
if relation not in {"derived_from", "specializes", "extends"}:
raise ValueError(f"Unsupported configuration package parent relation: {relation!r}.")
return cls(
package_id=_required_str(value, "package_id"),
version=_required_str(value, "version"),
relation=relation,
)
def to_dict(self) -> dict[str, str]:
return {
"package_id": self.package_id,
"version": self.version,
"relation": self.relation,
}
@dataclass(frozen=True, slots=True)
class ConfigurationPackageEvidence:
kind: ConfigurationPackageEvidenceKind
reference: str
summary: str
checksum: str | None = None
def __post_init__(self) -> None:
if self.kind not in CONFIGURATION_PACKAGE_EVIDENCE_KINDS:
raise ValueError(
f"Unsupported configuration package evidence kind: {self.kind!r}."
)
if not self.reference.strip() or not self.summary.strip():
raise ValueError(
"Configuration package evidence reference and summary are required."
)
if self.checksum is not None and not _SHA256_RE.fullmatch(self.checksum):
raise ValueError(
"Configuration package evidence checksum must use sha256:<64 lowercase hex>."
)
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationPackageEvidence":
kind = _required_str(value, "kind")
if kind not in CONFIGURATION_PACKAGE_EVIDENCE_KINDS:
raise ValueError(f"Unsupported configuration package evidence kind: {kind!r}.")
return cls(
kind=kind,
reference=_required_str(value, "reference"),
summary=_required_str(value, "summary"),
checksum=_optional_str(value, "checksum"),
)
def to_dict(self) -> dict[str, object]:
return {
"kind": self.kind,
"reference": self.reference,
"summary": self.summary,
"checksum": self.checksum,
}
@dataclass(frozen=True, slots=True)
class ConfigurationProviderExpectation:
provider_id: str
authority_mode: SourceAuthorityMode
minimum_maturity: IntegrationMaturity
binding_ref: str | None = None
health_expectation: str = "healthy"
freshness_expectation: str | None = None
recovery_expectation: str | None = None
def __post_init__(self) -> None:
if not self.provider_id.strip():
raise ValueError("Configuration provider expectation id is required.")
if self.authority_mode not in SOURCE_AUTHORITY_MODES:
raise ValueError(
f"Unsupported provider authority mode: {self.authority_mode!r}."
)
integration_maturity_rank(self.minimum_maturity)
if not self.health_expectation.strip():
raise ValueError("Provider health expectation is required.")
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationProviderExpectation":
return cls(
provider_id=_required_str(value, "provider_id"),
authority_mode=_required_str(value, "authority_mode"),
minimum_maturity=_required_str(value, "minimum_maturity"),
binding_ref=_optional_str(value, "binding_ref"),
health_expectation=_optional_str(value, "health_expectation") or "healthy",
freshness_expectation=_optional_str(value, "freshness_expectation"),
recovery_expectation=_optional_str(value, "recovery_expectation"),
)
def to_dict(self) -> dict[str, object]:
return {
"provider_id": self.provider_id,
"authority_mode": self.authority_mode,
"minimum_maturity": self.minimum_maturity,
"binding_ref": self.binding_ref,
"health_expectation": self.health_expectation,
"freshness_expectation": self.freshness_expectation,
"recovery_expectation": self.recovery_expectation,
}
@dataclass(frozen=True, slots=True)
@@ -75,6 +242,7 @@ class ConfigurationPackageManifest:
package_id: str
name: str
version: str
package_class: ConfigurationPackageClass = "product"
description: str | None = None
publisher: str | None = None
category: str | None = None
@@ -88,6 +256,29 @@ class ConfigurationPackageManifest:
artifact_ref: str | None = None
artifact_sha256: str | None = None
signature: Mapping[str, Any] | None = None
parents: tuple[ConfigurationPackageParent, ...] = ()
evidence: tuple[ConfigurationPackageEvidence, ...] = ()
provider_expectations: tuple[ConfigurationProviderExpectation, ...] = ()
def __post_init__(self) -> None:
if self.package_class not in CONFIGURATION_PACKAGE_CLASSES:
raise ValueError(
f"Unsupported configuration package class: {self.package_class!r}."
)
parent_keys = {(item.package_id, item.version) for item in self.parents}
if len(parent_keys) != len(self.parents):
raise ValueError("Configuration package parents must be unique.")
evidence_keys = {(item.kind, item.reference) for item in self.evidence}
if len(evidence_keys) != len(self.evidence):
raise ValueError("Configuration package evidence must be unique.")
provider_ids = [item.provider_id for item in self.provider_expectations]
if len(provider_ids) != len(set(provider_ids)):
raise ValueError("Configuration package provider expectations must be unique.")
for expectation in self.provider_expectations:
integration_maturity_rank(expectation.minimum_maturity)
issues = configuration_package_claim_issues(self)
if issues:
raise ValueError("Invalid configuration package claim: " + "; ".join(issues))
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationPackageManifest":
@@ -95,6 +286,7 @@ class ConfigurationPackageManifest:
package_id=_required_str(value, "package_id"),
name=_required_str(value, "name"),
version=_required_str(value, "version"),
package_class=_optional_str(value, "package_class") or "product",
description=_optional_str(value, "description"),
publisher=_optional_str(value, "publisher"),
category=_optional_str(value, "category"),
@@ -108,6 +300,21 @@ class ConfigurationPackageManifest:
artifact_ref=_optional_str(value, "artifact_ref"),
artifact_sha256=_optional_str(value, "artifact_sha256"),
signature=value.get("signature") if isinstance(value.get("signature"), Mapping) else None,
parents=tuple(
ConfigurationPackageParent.from_mapping(item)
for item in _object_list(value.get("parents"), field_name="parents")
),
evidence=tuple(
ConfigurationPackageEvidence.from_mapping(item)
for item in _object_list(value.get("evidence"), field_name="evidence")
),
provider_expectations=tuple(
ConfigurationProviderExpectation.from_mapping(item)
for item in _object_list(
value.get("provider_expectations"),
field_name="provider_expectations",
)
),
)
def to_dict(self) -> dict[str, object]:
@@ -115,12 +322,18 @@ class ConfigurationPackageManifest:
"package_id": self.package_id,
"name": self.name,
"version": self.version,
"package_class": self.package_class,
"required_modules": [item.to_dict() for item in self.required_modules],
"required_capabilities": list(self.required_capabilities),
"optional_modules": [item.to_dict() for item in self.optional_modules],
"fragments": [item.to_dict() for item in self.fragments],
"data_requirements": [dict(item) for item in self.data_requirements],
"tags": list(self.tags),
"parents": [item.to_dict() for item in self.parents],
"evidence": [item.to_dict() for item in self.evidence],
"provider_expectations": [
item.to_dict() for item in self.provider_expectations
],
}
for key, value in (
("description", self.description),
@@ -221,6 +434,12 @@ class ConfigurationPreflightContext:
supplied_data: Mapping[str, Any] = field(default_factory=dict)
installed_modules: Mapping[str, str] = field(default_factory=dict)
capabilities: frozenset[str] = frozenset()
external_provider_declarations: Mapping[str, Mapping[str, Any]] = field(
default_factory=dict
)
external_provider_states: Mapping[str, Mapping[str, Any]] = field(
default_factory=dict
)
dry_run: bool = True
@@ -286,6 +505,7 @@ def dry_run_configuration_package(
diagnostics.extend(_module_requirement_diagnostics(manifest, context))
diagnostics.extend(_capability_requirement_diagnostics(manifest, context))
diagnostics.extend(_provider_expectation_diagnostics(manifest, context))
for item in manifest.data_requirements:
requirement = ConfigurationRequiredData.from_mapping(item)
required_data.append(requirement)
@@ -369,6 +589,8 @@ def apply_configuration_package(
supplied_data=supplied_data if supplied_data is not None else context.supplied_data,
installed_modules=context.installed_modules,
capabilities=context.capabilities,
external_provider_declarations=context.external_provider_declarations,
external_provider_states=context.external_provider_states,
dry_run=False,
)
preflight = dry_run_configuration_package(manifest, providers, apply_context)
@@ -665,6 +887,154 @@ def record_configuration_package_catalog_acceptance(validation: dict[str, object
state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def configuration_package_claim_issues(
manifest: ConfigurationPackageManifest,
) -> tuple[str, ...]:
evidence_kinds = {item.kind for item in manifest.evidence}
required_evidence: dict[str, frozenset[str]] = {
"reference": frozenset(
{
"target_test",
"recovery",
"security",
"operations",
"accessibility",
"privacy",
"documentation",
}
),
"product": frozenset(),
"sector": frozenset({"documentation"}),
"deployment": frozenset(
{"target_test", "recovery", "security", "operations"}
),
"integration": frozenset(
{"target_test", "recovery", "operations", "documentation"}
),
}
issues: list[str] = []
missing = sorted(required_evidence[manifest.package_class] - evidence_kinds)
if missing:
issues.append(
f"{manifest.package_class} package is missing evidence: "
+ ", ".join(missing)
)
if manifest.package_class in {"reference", "deployment", "integration"}:
unbound = sorted(
item.kind
for item in manifest.evidence
if item.kind != "documentation" and item.checksum is None
)
if unbound:
issues.append(
f"{manifest.package_class} package has evidence without checksums: "
+ ", ".join(unbound)
)
if manifest.package_class == "sector" and not manifest.parents:
issues.append("sector packages must declare a parent package/version")
if manifest.package_class == "integration" and not manifest.provider_expectations:
issues.append("integration packages must declare external provider expectations")
return tuple(issues)
def validate_configuration_package_derivation(
child: ConfigurationPackageManifest,
parent: ConfigurationPackageManifest,
) -> tuple[ConfigurationDiagnostic, ...]:
diagnostics: list[ConfigurationDiagnostic] = []
if not any(
item.package_id == parent.package_id and item.version == parent.version
for item in child.parents
):
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="package_parent_provenance_missing",
message=(
f"Package {child.package_id!r} does not declare parent "
f"{parent.package_id}@{parent.version}."
),
object_ref=parent.package_id,
)
)
child_modules = {item.module_id: item for item in child.required_modules}
for requirement in parent.required_modules:
candidate = child_modules.get(requirement.module_id)
if candidate is None or (
requirement.version is not None
and candidate.version != requirement.version
):
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="package_parent_module_constraint_loosened",
message=(
f"Derived package loosens parent module requirement "
f"{requirement.module_id!r}."
),
module_id=requirement.module_id,
)
)
for capability in set(parent.required_capabilities) - set(
child.required_capabilities
):
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="package_parent_capability_constraint_loosened",
message=(
f"Derived package removes required capability {capability!r}."
),
object_ref=capability,
)
)
child_providers = {
item.provider_id: item for item in child.provider_expectations
}
for expectation in parent.provider_expectations:
candidate = child_providers.get(expectation.provider_id)
if candidate is None:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="package_parent_provider_constraint_removed",
message=(
f"Derived package removes provider expectation "
f"{expectation.provider_id!r}."
),
object_ref=expectation.provider_id,
)
)
continue
if candidate.authority_mode != expectation.authority_mode:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="package_parent_authority_mode_changed",
message=(
f"Derived package changes authority mode for provider "
f"{expectation.provider_id!r}."
),
object_ref=expectation.provider_id,
)
)
if integration_maturity_rank(
candidate.minimum_maturity
) < integration_maturity_rank(expectation.minimum_maturity):
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="package_parent_provider_maturity_loosened",
message=(
f"Derived package lowers provider maturity for "
f"{expectation.provider_id!r}."
),
object_ref=expectation.provider_id,
)
)
return tuple(diagnostics)
def _configuration_package_manifest(package: ConfigurationPackageManifest | Mapping[str, Any]) -> ConfigurationPackageManifest:
if isinstance(package, ConfigurationPackageManifest):
return package
@@ -716,6 +1086,194 @@ def _capability_requirement_diagnostics(manifest: ConfigurationPackageManifest,
return diagnostics
def _provider_expectation_diagnostics(
manifest: ConfigurationPackageManifest,
context: ConfigurationPreflightContext,
) -> list[ConfigurationDiagnostic]:
diagnostics: list[ConfigurationDiagnostic] = []
for expectation in manifest.provider_expectations:
declaration = context.external_provider_declarations.get(
expectation.provider_id
)
if declaration is None:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="external_provider_missing",
message=(
f"Required external provider {expectation.provider_id!r} "
"is not installed or declared."
),
object_ref=expectation.provider_id,
resolution=(
"Install and enable a module exposing the declared provider."
),
)
)
continue
supported_modes = {
str(item)
for item in declaration.get("authority_modes", ())
if str(item).strip()
}
if expectation.authority_mode not in supported_modes:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="external_provider_authority_mode_unsupported",
message=(
f"Provider {expectation.provider_id!r} does not support "
f"authority mode {expectation.authority_mode!r}."
),
object_ref=expectation.provider_id,
)
)
actual_maturity = str(declaration.get("maturity") or "discover")
try:
maturity_sufficient = integration_maturity_rank(
cast(IntegrationMaturity, actual_maturity)
) >= integration_maturity_rank(expectation.minimum_maturity)
except ValueError:
maturity_sufficient = False
if not maturity_sufficient:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="external_provider_maturity_insufficient",
message=(
f"Provider {expectation.provider_id!r} has maturity "
f"{actual_maturity!r}; {expectation.minimum_maturity!r} is required."
),
object_ref=expectation.provider_id,
)
)
provider_state = context.external_provider_states.get(
expectation.provider_id
)
if provider_state is None:
diagnostics.append(
ConfigurationDiagnostic(
severity="warning",
code="external_provider_health_unverified",
message=(
f"Provider {expectation.provider_id!r} has no current "
"health/freshness observation."
),
object_ref=expectation.provider_id,
)
)
continue
state = provider_state
if expectation.binding_ref is not None:
bindings = provider_state.get("bindings")
matching_binding = next(
(
item
for item in bindings
if isinstance(item, Mapping)
and str(item.get("binding_ref") or "")
== expectation.binding_ref
),
None,
) if isinstance(bindings, Sequence) and not isinstance(
bindings, (str, bytes)
) else None
if matching_binding is None and str(
provider_state.get("binding_ref") or ""
) == expectation.binding_ref:
matching_binding = provider_state
if matching_binding is None:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="external_provider_binding_mismatch",
message=(
f"Provider {expectation.provider_id!r} is not observed through "
f"required binding {expectation.binding_ref!r}."
),
object_ref=expectation.provider_id,
)
)
continue
state = matching_binding
health = str(state.get("health") or state.get("health_state") or "unknown")
accepted_health = (
{"ok", "healthy"}
if expectation.health_expectation == "healthy"
else {expectation.health_expectation}
)
if health not in accepted_health:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="external_provider_unhealthy",
message=(
f"Provider {expectation.provider_id!r} health is {health!r}."
),
object_ref=expectation.provider_id,
resolution="Restore provider health or use a documented degraded path.",
)
)
observed_authority_mode = str(state.get("authority_mode") or "")
if (
observed_authority_mode
and observed_authority_mode != expectation.authority_mode
):
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="external_provider_binding_authority_mismatch",
message=(
f"Provider {expectation.provider_id!r} is configured as "
f"{observed_authority_mode!r}; {expectation.authority_mode!r} "
"is required."
),
object_ref=expectation.provider_id,
)
)
if expectation.freshness_expectation is not None:
freshness = str(
state.get("freshness")
or state.get("freshness_state")
or "unknown"
)
if freshness != expectation.freshness_expectation:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="external_provider_freshness_expectation_failed",
message=(
f"Provider {expectation.provider_id!r} freshness is "
f"{freshness!r}; {expectation.freshness_expectation!r} is required."
),
object_ref=expectation.provider_id,
)
)
if expectation.recovery_expectation is not None:
behavior = declaration.get("behavior")
declared_recovery = (
behavior.get(expectation.recovery_expectation)
if isinstance(behavior, Mapping)
else None
)
observed_recovery = state.get("recovery") or state.get(
"recovery_state"
)
if not declared_recovery and observed_recovery != expectation.recovery_expectation:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="external_provider_recovery_expectation_failed",
message=(
f"Provider {expectation.provider_id!r} does not satisfy "
f"recovery expectation {expectation.recovery_expectation!r}."
),
object_ref=expectation.provider_id,
)
)
return diagnostics
def _dedupe_diagnostics(items: Sequence[ConfigurationDiagnostic]) -> list[ConfigurationDiagnostic]:
seen: set[tuple[object, ...]] = set()
result: list[ConfigurationDiagnostic] = []
+193 -14
View File
@@ -1,8 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from collections.abc import Mapping
from typing import Any, Literal
from typing import Literal
from govoplan_core.security.permissions import scopes_grant
from govoplan_core.security.redaction import contains_plain_secret
@@ -22,7 +21,7 @@ class ConfigurationFieldSafety:
storage: str
ui_managed: bool
risk: ConfigurationRisk
secret_handling: SecretHandling = "none"
secret_handling: SecretHandling = "none" # noqa: S105 - policy vocabulary.
required_scopes: tuple[str, ...] = ()
dry_run_required: bool = False
validation_required: bool = True
@@ -69,7 +68,7 @@ class ConfigurationChangeSafetyPlan:
maintenance_required: bool = False
maintenance_satisfied: bool = False
rollback_history_required: bool = False
secret_handling: SecretHandling = "none"
secret_handling: SecretHandling = "none" # noqa: S105 - policy vocabulary.
audit_event: str | None = None
policy_explanation: str | None = None
blockers: tuple[str, ...] = ()
@@ -240,7 +239,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
storage="module_settings",
ui_managed=True,
risk="high",
secret_handling="reference_only",
secret_handling="reference_only", # noqa: S106 # nosec B106 - policy vocabulary.
required_scopes=("mail_servers:manage_credentials",),
validation_required=True,
audit_event="mail_server_profile.credential_updated",
@@ -256,14 +255,14 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
storage="module_settings",
ui_managed=True,
risk="high",
secret_handling="reference_only",
secret_handling="reference_only", # noqa: S106 # nosec B106 - policy vocabulary.
required_scopes=("files:file:admin",),
dry_run_required=True,
policy_explanation_required=True,
audit_event="files.connector_profile.updated",
two_person_approval_required=True,
rollback_history_required=True,
notes="Connector endpoints are UI-manageable, but passwords/tokens remain env or secret refs.",
notes="Connector endpoints are UI-manageable. API-managed credentials are encrypted or use scoped secret refs; process-environment references remain deployment-owned.",
),
ConfigurationFieldSafety(
key="DATABASE_URL",
@@ -273,7 +272,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
storage="environment",
ui_managed=False,
risk="destructive",
secret_handling="env_only",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Database connectivity remains deployment-managed and must not be changed from the running UI.",
),
@@ -285,11 +284,191 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
storage="environment",
ui_managed=False,
risk="destructive",
secret_handling="env_only",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
two_person_approval_required=True,
notes="Encryption roots remain out of band; UI may only report missing/rotated state.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
label="Private-network connector access",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="This deployment-wide egress boundary remains out of band and applies to every connector worker.",
),
ConfigurationFieldSafety(
key="FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
label="Installer-managed Garage trust",
owner_module="files",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Reserved for the exact installer-owned http://garage:3900 service; it must never authorize another S3 endpoint.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
label="Structured connector response limit",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="A deployment-wide memory-safety limit applied consistently by API and worker processes.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES",
label="Connector file-transfer limit",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="A deployment-wide hard ceiling; individual module upload policies may impose lower limits.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
label="Connector secret environment allowlist",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="destructive",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Exact environment names available only to deployment-owned connector profiles; tenant/API profiles cannot select process variables.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST",
label="Connector CA bundle allowlist",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Exact absolute CA bundle paths approved by the deployment and mounted consistently on every connector worker.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES",
label="HTTP request-body limit",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="A deployment-wide hard ceiling; endpoint-specific upload policies may impose lower limits.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_HTTP_HSTS_SECONDS",
label="HTTP Strict Transport Security duration",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Deployment-managed browser transport policy; use only after the public service is HTTPS-only.",
),
ConfigurationFieldSafety(
key="AUTH_LOGIN_THROTTLE_ENABLED",
label="Interactive login throttling",
owner_module="access",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Disabling the deployment login throttle weakens protection against password guessing.",
),
ConfigurationFieldSafety(
key="AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT",
label="Login failures per identity",
owner_module="access",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Shared through Redis when available, with a bounded process-local fallback.",
),
ConfigurationFieldSafety(
key="AUTH_LOGIN_THROTTLE_CLIENT_LIMIT",
label="Login failures per client",
owner_module="access",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Counts the direct peer after the deployment's trusted-proxy boundary is applied.",
),
ConfigurationFieldSafety(
key="AUTH_LOGIN_THROTTLE_WINDOW_SECONDS",
label="Login throttle window",
owner_module="access",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Fixed counter window shared by identity and direct-client limits.",
),
ConfigurationFieldSafety(
key="AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS",
label="Login throttle Redis retry interval",
owner_module="access",
scope="system",
storage="environment",
ui_managed=False,
risk="medium",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Controls how quickly a worker retries the distributed counter after falling back locally.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_TRUSTED_HOSTS",
label="Trusted HTTP hosts",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Host-header validation is deployment-managed and must cover every public API host.",
),
ConfigurationFieldSafety(
key="FORWARDED_ALLOW_IPS",
label="Trusted reverse proxy addresses",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Uvicorn trusts forwarded client and scheme data only from this deployment-managed boundary.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS",
label="Module package catalog trusted keys",
@@ -298,7 +477,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
notes="Trust roots are deployment-managed; UI can validate catalogs but should not edit key material.",
),
ConfigurationFieldSafety(
@@ -309,7 +488,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
notes="Configuration package trust roots are deployment-managed.",
),
)
@@ -414,9 +593,9 @@ def _configuration_change_safety_state(
approval_satisfied = not approval_required or approval_count >= 2
if approval_required and not approval_satisfied:
blockers.append("two_person_approval_required")
if field.secret_handling == "reference_only" and _contains_plain_secret(value):
if field.secret_handling == "reference_only" and _contains_plain_secret(value): # noqa: S105 # nosec B105 - policy vocabulary.
blockers.append("secret_reference_required")
if field.secret_handling == "env_only" and value is not None:
if field.secret_handling == "env_only" and value is not None: # noqa: S105 # nosec B105 - policy vocabulary.
blockers.append("env_only_secret")
if field.rollback_history_required:
warnings.append("rollback_history_required")
@@ -466,7 +645,7 @@ def _policy_explanation(field: ConfigurationFieldSafety) -> str:
parts.append("requires two-person approval")
if field.maintenance_required:
parts.append("requires maintenance mode")
if field.secret_handling != "none":
if field.secret_handling != "none": # noqa: S105 # nosec B105 - policy vocabulary.
parts.append(f"uses {field.secret_handling} secret handling")
return "; ".join(parts) + "."
+289
View File
@@ -0,0 +1,289 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
from govoplan_core.core.automation import AutomationInvocation
from govoplan_core.core.events import PlatformEvent
CAPABILITY_DATAFLOW_RUN_LIFECYCLE = "dataflow.runLifecycle"
CAPABILITY_DATAFLOW_RUN_WORKER = "dataflow.runWorker"
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER = "dataflow.triggerDispatcher"
CAPABILITY_DATAFLOW_DATASET_OUTPUT = "dataflow.dataset_output"
class DataflowRunError(ValueError):
"""Stable base error for module-neutral Dataflow run operations."""
class DataflowRunNotFoundError(DataflowRunError):
pass
class DataflowRunConflictError(DataflowRunError):
pass
class DataflowRunUnavailableError(DataflowRunError):
pass
@dataclass(frozen=True, slots=True)
class DataflowDatasetDescriptor:
pipeline_ref: str
name: str
revision: int
definition_hash: str
status: str
description: str | None = None
updated_at: datetime | None = None
parameters: Mapping[str, object] = field(default_factory=dict)
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DataflowDatasetRequest:
pipeline_ref: str
revision: int
parameters: Mapping[str, object] = field(default_factory=dict)
row_limit: int = 500
expected_definition_hash: str | None = None
expected_source_fingerprints: tuple[Mapping[str, object], ...] = ()
@dataclass(frozen=True, slots=True)
class DataflowDatasetResult:
pipeline_ref: str
revision: int
definition_hash: str
rows: tuple[Mapping[str, object], ...]
total_rows: int
truncated: bool
output_hash: str
executor_version: str
run_ref: str | None = None
source_fingerprints: tuple[Mapping[str, object], ...] = ()
diagnostics: tuple[Mapping[str, object], ...] = ()
generated_at: datetime | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class DataflowDatasetOutputProvider(Protocol):
def list_outputs(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
) -> Sequence[DataflowDatasetDescriptor]: ...
def read_output(
self,
session: object,
principal: object,
*,
request: DataflowDatasetRequest,
) -> DataflowDatasetResult: ...
@dataclass(frozen=True, slots=True)
class DataflowPublicationTarget:
target_datasource_ref: str | None = None
name: str | None = None
source_name: str | None = None
description: str | None = None
freeze: bool = False
frozen_label: str | None = None
set_current: bool = True
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DataflowRunRequest:
pipeline_ref: str
revision: int
idempotency_key: str
row_limit: int = 500
execution_backend: str = "auto"
environment: str = "development"
max_attempts: int = 3
retention_days: int = 30
publication: DataflowPublicationTarget | None = None
invocation: AutomationInvocation = field(
default_factory=AutomationInvocation
)
@dataclass(frozen=True, slots=True)
class DataflowRunDescriptor:
ref: str
pipeline_ref: str
revision: int
status: str
definition_hash: str
executor_version: str
input_row_count: int = 0
output_row_count: int = 0
output_publication_ref: str | None = None
output_datasource_ref: str | None = None
output_materialization_ref: str | None = None
invocation_kind: str = "manual"
trigger_ref: str | None = None
delivery_ref: str | None = None
error: str | None = None
started_at: datetime | None = None
finished_at: datetime | None = None
replayed: bool = False
metadata: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class DataflowRunLifecycleProvider(Protocol):
def start_run(
self,
session: object,
principal: object,
*,
request: DataflowRunRequest,
) -> DataflowRunDescriptor:
...
def get_run(
self,
session: object,
principal: object,
*,
run_ref: str,
) -> DataflowRunDescriptor | None:
...
def cancel_run(
self,
session: object,
principal: object,
*,
run_ref: str,
) -> DataflowRunDescriptor:
...
@runtime_checkable
class DataflowTriggerDispatcher(Protocol):
def dispatch_due(
self,
session: object,
*,
now: datetime | None = None,
limit: int = 50,
) -> Mapping[str, object]:
...
def ingest_event(
self,
session: object,
*,
event: PlatformEvent,
) -> Mapping[str, object]:
...
@runtime_checkable
class DataflowRunWorker(Protocol):
"""Durable worker boundary for queued Dataflow execution.
Implementations own claim transaction boundaries so a lease is committed
before potentially long-running execution starts.
"""
def dispatch_pending(
self,
session: object,
*,
now: datetime | None = None,
limit: int = 10,
worker_id: str | None = None,
) -> Mapping[str, object]:
...
def purge_expired(
self,
session: object,
*,
now: datetime | None = None,
limit: int = 500,
) -> Mapping[str, object]:
...
def dataflow_run_lifecycle(
registry: object | None,
) -> DataflowRunLifecycleProvider | None:
capability = _capability(registry, CAPABILITY_DATAFLOW_RUN_LIFECYCLE)
return capability if isinstance(capability, DataflowRunLifecycleProvider) else None
def dataflow_run_worker(
registry: object | None,
) -> DataflowRunWorker | None:
capability = _capability(registry, CAPABILITY_DATAFLOW_RUN_WORKER)
return capability if isinstance(capability, DataflowRunWorker) else None
def dataflow_trigger_dispatcher(
registry: object | None,
) -> DataflowTriggerDispatcher | None:
capability = _capability(registry, CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER)
return (
capability
if isinstance(capability, DataflowTriggerDispatcher)
else None
)
def dataflow_dataset_output(
registry: object | None,
) -> DataflowDatasetOutputProvider | None:
capability = _capability(registry, CAPABILITY_DATAFLOW_DATASET_OUTPUT)
return capability if isinstance(capability, DataflowDatasetOutputProvider) else None
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
__all__ = [
"CAPABILITY_DATAFLOW_RUN_LIFECYCLE",
"CAPABILITY_DATAFLOW_RUN_WORKER",
"CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER",
"CAPABILITY_DATAFLOW_DATASET_OUTPUT",
"DataflowDatasetDescriptor",
"DataflowDatasetOutputProvider",
"DataflowDatasetRequest",
"DataflowDatasetResult",
"DataflowPublicationTarget",
"DataflowRunConflictError",
"DataflowRunDescriptor",
"DataflowRunError",
"DataflowRunLifecycleProvider",
"DataflowRunNotFoundError",
"DataflowRunRequest",
"DataflowRunUnavailableError",
"DataflowRunWorker",
"DataflowTriggerDispatcher",
"dataflow_run_lifecycle",
"dataflow_run_worker",
"dataflow_trigger_dispatcher",
"dataflow_dataset_output",
]
+617
View File
@@ -0,0 +1,617 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.external_references import (
SOURCE_AUTHORITY_MODES,
SourceAuthorityMode,
)
CAPABILITY_DATASOURCE_CATALOGUE = "datasources.catalogue"
CAPABILITY_DATASOURCE_LIFECYCLE = "datasources.lifecycle"
CAPABILITY_DATASOURCE_PUBLICATION = "datasources.publication"
CAPABILITY_DATASOURCE_ORIGINS = "connectors.datasourceOrigins"
DatasourceMode = Literal["live", "cached", "static"]
DatasourceKind = Literal[
"upload",
"database",
"http",
"rest",
"directory",
"file",
"feed",
"custom",
]
DatasourceShape = Literal["tabular", "document", "binary", "directory", "stream"]
DatasourceConsistency = Literal["current", "live", "frozen"]
class DatasourceError(ValueError):
"""Stable base error for provider-neutral datasource operations."""
class DatasourceNotFoundError(DatasourceError):
pass
class DatasourceAccessError(DatasourceError):
pass
class DatasourceValidationError(DatasourceError):
pass
class DatasourceUnavailableError(DatasourceError):
pass
@dataclass(frozen=True, slots=True)
class DatasourceField:
name: str
data_type: str
nullable: bool = True
@dataclass(frozen=True, slots=True)
class DatasourceGovernance:
"""Provider-neutral governance facts attached to a datasource revision."""
owner_ref: str | None = None
steward_ref: str | None = None
responsible_organization_ref: str | None = None
responsible_function_ref: str | None = None
authoritative_source_ref: str | None = None
authority_mode: SourceAuthorityMode = "linked_reference"
legal_basis_refs: tuple[str, ...] = ()
purposes: tuple[str, ...] = ()
semantic_definition: str | None = None
schema_owner_ref: str | None = None
official_keys: tuple[str, ...] = ()
classification: str = "internal"
privacy_profile_ref: str | None = None
retention_policy_ref: str | None = None
hold_refs: tuple[str, ...] = ()
publication_state: str = "draft"
transfer_agreement_ref: str | None = None
freshness_policy: Mapping[str, object] = field(default_factory=dict)
quality_policy: Mapping[str, object] = field(default_factory=dict)
known_limits: tuple[str, ...] = ()
correction_procedure_ref: str | None = None
affected_refs: tuple[str, ...] = ()
dependency_refs: tuple[str, ...] = ()
def __post_init__(self) -> None:
if self.authority_mode not in SOURCE_AUTHORITY_MODES:
raise DatasourceValidationError(
f"Unsupported datasource authority mode: {self.authority_mode!r}."
)
if not self.classification.strip():
raise DatasourceValidationError("Datasource classification is required.")
if not self.publication_state.strip():
raise DatasourceValidationError("Datasource publication state is required.")
for field_name in (
"legal_basis_refs",
"purposes",
"official_keys",
"hold_refs",
"known_limits",
"affected_refs",
"dependency_refs",
):
values = getattr(self, field_name)
if any(not value.strip() for value in values):
raise DatasourceValidationError(
f"Datasource governance {field_name} cannot contain empty values."
)
if len(values) != len(set(values)):
raise DatasourceValidationError(
f"Datasource governance {field_name} cannot contain duplicates."
)
@classmethod
def from_mapping(cls, value: Mapping[str, object] | None) -> "DatasourceGovernance":
source = value or {}
return cls(
owner_ref=_optional_governance_text(source.get("owner_ref")),
steward_ref=_optional_governance_text(source.get("steward_ref")),
responsible_organization_ref=_optional_governance_text(
source.get("responsible_organization_ref")
),
responsible_function_ref=_optional_governance_text(
source.get("responsible_function_ref")
),
authoritative_source_ref=_optional_governance_text(
source.get("authoritative_source_ref")
),
authority_mode=str(
source.get("authority_mode") or "linked_reference"
), # type: ignore[arg-type]
legal_basis_refs=_governance_texts(source.get("legal_basis_refs")),
purposes=_governance_texts(source.get("purposes")),
semantic_definition=_optional_governance_text(
source.get("semantic_definition")
),
schema_owner_ref=_optional_governance_text(source.get("schema_owner_ref")),
official_keys=_governance_texts(source.get("official_keys")),
classification=str(source.get("classification") or "internal"),
privacy_profile_ref=_optional_governance_text(
source.get("privacy_profile_ref")
),
retention_policy_ref=_optional_governance_text(
source.get("retention_policy_ref")
),
hold_refs=_governance_texts(source.get("hold_refs")),
publication_state=str(source.get("publication_state") or "draft"),
transfer_agreement_ref=_optional_governance_text(
source.get("transfer_agreement_ref")
),
freshness_policy=_governance_mapping(source.get("freshness_policy")),
quality_policy=_governance_mapping(source.get("quality_policy")),
known_limits=_governance_texts(source.get("known_limits")),
correction_procedure_ref=_optional_governance_text(
source.get("correction_procedure_ref")
),
affected_refs=_governance_texts(source.get("affected_refs")),
dependency_refs=_governance_texts(source.get("dependency_refs")),
)
def to_dict(self) -> dict[str, object]:
return {
"owner_ref": self.owner_ref,
"steward_ref": self.steward_ref,
"responsible_organization_ref": self.responsible_organization_ref,
"responsible_function_ref": self.responsible_function_ref,
"authoritative_source_ref": self.authoritative_source_ref,
"authority_mode": self.authority_mode,
"legal_basis_refs": list(self.legal_basis_refs),
"purposes": list(self.purposes),
"semantic_definition": self.semantic_definition,
"schema_owner_ref": self.schema_owner_ref,
"official_keys": list(self.official_keys),
"classification": self.classification,
"privacy_profile_ref": self.privacy_profile_ref,
"retention_policy_ref": self.retention_policy_ref,
"hold_refs": list(self.hold_refs),
"publication_state": self.publication_state,
"transfer_agreement_ref": self.transfer_agreement_ref,
"freshness_policy": dict(self.freshness_policy),
"quality_policy": dict(self.quality_policy),
"known_limits": list(self.known_limits),
"correction_procedure_ref": self.correction_procedure_ref,
"affected_refs": list(self.affected_refs),
"dependency_refs": list(self.dependency_refs),
}
@dataclass(frozen=True, slots=True)
class DatasourceDescriptor:
ref: str
source_name: str
name: str
kind: DatasourceKind
mode: DatasourceMode
shape: DatasourceShape
status: str = "active"
description: str | None = None
provider: str | None = None
provider_ref: str | None = None
schema: tuple[DatasourceField, ...] = ()
schema_version: str = "1"
fingerprint: str = ""
current_materialization_ref: str | None = None
row_count: int | None = None
byte_count: int | None = None
updated_at: datetime | None = None
capabilities: tuple[str, ...] = ("read",)
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
governance: DatasourceGovernance = field(default_factory=DatasourceGovernance)
@dataclass(frozen=True, slots=True)
class DatasourceMaterialization:
ref: str
datasource_ref: str
revision: int
state: str
fingerprint: str
schema: tuple[DatasourceField, ...] = ()
row_count: int | None = None
byte_count: int | None = None
frozen_at: datetime | None = None
frozen_label: str | None = None
source_timestamp: datetime | None = None
created_at: datetime | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
governance: DatasourceGovernance = field(default_factory=DatasourceGovernance)
@dataclass(frozen=True, slots=True)
class DatasourceStage:
ref: str
name: str
source_name: str
kind: DatasourceKind
mode: DatasourceMode
shape: DatasourceShape
state: str
target_datasource_ref: str | None = None
fingerprint: str = ""
schema: tuple[DatasourceField, ...] = ()
row_count: int | None = None
byte_count: int | None = None
validation: Mapping[str, object] = field(default_factory=dict)
created_at: datetime | None = None
promoted_at: datetime | None = None
promoted_materialization_ref: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
governance: DatasourceGovernance = field(default_factory=DatasourceGovernance)
@dataclass(frozen=True, slots=True)
class DatasourceReadRequest:
datasource_ref: str
materialization_ref: str | None = None
consistency: DatasourceConsistency = "current"
limit: int = 250
offset: int = 0
columns: tuple[str, ...] = ()
expected_fingerprint: str | None = None
@dataclass(frozen=True, slots=True)
class DatasourceReadResult:
datasource: DatasourceDescriptor
rows: tuple[Mapping[str, object], ...]
total_rows: int
truncated: bool
materialization: DatasourceMaterialization | None = None
@dataclass(frozen=True, slots=True)
class DatasourceStageInput:
name: str
source_name: str
kind: DatasourceKind
mode: DatasourceMode
shape: DatasourceShape
rows: tuple[Mapping[str, object], ...]
description: str | None = None
target_datasource_ref: str | None = None
provider: str | None = None
provider_ref: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
governance: DatasourceGovernance | None = None
@dataclass(frozen=True, slots=True)
class DatasourcePublicationRequest:
producer_module: str
producer_run_ref: str
idempotency_key: str
rows: tuple[Mapping[str, object], ...]
target_datasource_ref: str | None = None
name: str | None = None
source_name: str | None = None
description: str | None = None
freeze: bool = False
frozen_label: str | None = None
set_current: bool = True
source_timestamp: datetime | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
governance: DatasourceGovernance | None = None
@dataclass(frozen=True, slots=True)
class DatasourcePublicationResult:
ref: str
status: str
datasource: DatasourceDescriptor
materialization: DatasourceMaterialization
replayed: bool = False
@dataclass(frozen=True, slots=True)
class DatasourceOrigin:
ref: str
source_name: str
name: str
kind: DatasourceKind
shape: DatasourceShape
supported_modes: tuple[DatasourceMode, ...]
provider: str
description: str | None = None
schema: tuple[DatasourceField, ...] = ()
schema_version: str = "1"
fingerprint: str = ""
row_count: int | None = None
byte_count: int | None = None
updated_at: datetime | None = None
capabilities: tuple[str, ...] = ("read",)
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DatasourceOriginReadRequest:
origin_ref: str
limit: int = 250
offset: int = 0
columns: tuple[str, ...] = ()
expected_fingerprint: str | None = None
@dataclass(frozen=True, slots=True)
class DatasourceOriginReadResult:
origin: DatasourceOrigin
rows: tuple[Mapping[str, object], ...]
total_rows: int
truncated: bool
@runtime_checkable
class DatasourceCatalogueProvider(Protocol):
def list_datasources(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
authority_mode: str | None = None,
classification: str | None = None,
publication_state: str | None = None,
owner_ref: str | None = None,
responsible_organization_ref: str | None = None,
affected_ref: str | None = None,
dependency_ref: str | None = None,
) -> Sequence[DatasourceDescriptor]:
...
def get_datasource(
self,
session: object,
principal: object,
*,
datasource_ref: str,
) -> DatasourceDescriptor | None:
...
def read_datasource(
self,
session: object,
principal: object,
*,
request: DatasourceReadRequest,
) -> DatasourceReadResult:
...
def list_materializations(
self,
session: object,
principal: object,
*,
datasource_ref: str,
) -> Sequence[DatasourceMaterialization]:
...
@runtime_checkable
class DatasourceLifecycleProvider(Protocol):
def list_stages(
self,
session: object,
principal: object,
*,
limit: int = 100,
) -> Sequence[DatasourceStage]:
...
def create_stage(
self,
session: object,
principal: object,
*,
stage: DatasourceStageInput,
) -> DatasourceStage:
...
def promote_stage(
self,
session: object,
principal: object,
*,
stage_ref: str,
freeze: bool = False,
frozen_label: str | None = None,
) -> tuple[DatasourceDescriptor, DatasourceMaterialization]:
...
def register_origin(
self,
session: object,
principal: object,
*,
origin_ref: str,
name: str,
source_name: str,
mode: DatasourceMode,
description: str | None = None,
governance: DatasourceGovernance | None = None,
) -> DatasourceDescriptor:
...
def update_datasource_governance(
self,
session: object,
principal: object,
*,
datasource_ref: str,
governance: DatasourceGovernance,
) -> DatasourceDescriptor:
...
def refresh_datasource(
self,
session: object,
principal: object,
*,
datasource_ref: str,
) -> tuple[DatasourceDescriptor, DatasourceMaterialization]:
...
def freeze_datasource(
self,
session: object,
principal: object,
*,
datasource_ref: str,
label: str | None = None,
) -> DatasourceMaterialization:
...
def retire_datasource(
self,
session: object,
principal: object,
*,
datasource_ref: str,
) -> DatasourceDescriptor:
...
@runtime_checkable
class DatasourcePublicationProvider(Protocol):
def publish_rows(
self,
session: object,
principal: object,
*,
request: DatasourcePublicationRequest,
) -> DatasourcePublicationResult:
...
@runtime_checkable
class DatasourceOriginProvider(Protocol):
def list_origins(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
) -> Sequence[DatasourceOrigin]:
...
def get_origin(
self,
session: object,
principal: object,
*,
origin_ref: str,
) -> DatasourceOrigin | None:
...
def read_origin(
self,
session: object,
principal: object,
*,
request: DatasourceOriginReadRequest,
) -> DatasourceOriginReadResult:
...
def datasource_catalogue(registry: object | None) -> DatasourceCatalogueProvider | None:
capability = _capability(registry, CAPABILITY_DATASOURCE_CATALOGUE)
return capability if isinstance(capability, DatasourceCatalogueProvider) else None
def datasource_lifecycle(registry: object | None) -> DatasourceLifecycleProvider | None:
capability = _capability(registry, CAPABILITY_DATASOURCE_LIFECYCLE)
return capability if isinstance(capability, DatasourceLifecycleProvider) else None
def datasource_publication(
registry: object | None,
) -> DatasourcePublicationProvider | None:
capability = _capability(registry, CAPABILITY_DATASOURCE_PUBLICATION)
return capability if isinstance(capability, DatasourcePublicationProvider) else None
def datasource_origins(registry: object | None) -> DatasourceOriginProvider | None:
capability = _capability(registry, CAPABILITY_DATASOURCE_ORIGINS)
return capability if isinstance(capability, DatasourceOriginProvider) else None
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
def _optional_governance_text(value: object) -> str | None:
if value is None:
return None
cleaned = str(value).strip()
return cleaned or None
def _governance_texts(value: object) -> tuple[str, ...]:
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
return ()
return tuple(str(item).strip() for item in value)
def _governance_mapping(value: object) -> Mapping[str, object]:
if not isinstance(value, Mapping):
return {}
return {str(key): item for key, item in value.items()}
__all__ = [
"CAPABILITY_DATASOURCE_CATALOGUE",
"CAPABILITY_DATASOURCE_LIFECYCLE",
"CAPABILITY_DATASOURCE_ORIGINS",
"DatasourceAccessError",
"DatasourceCatalogueProvider",
"DatasourceConsistency",
"DatasourceDescriptor",
"DatasourceError",
"DatasourceField",
"DatasourceGovernance",
"DatasourceKind",
"DatasourceLifecycleProvider",
"DatasourceMaterialization",
"DatasourceMode",
"DatasourceNotFoundError",
"DatasourceOrigin",
"DatasourceOriginProvider",
"DatasourceOriginReadRequest",
"DatasourceOriginReadResult",
"DatasourceReadRequest",
"DatasourceReadResult",
"DatasourceShape",
"DatasourceStage",
"DatasourceStageInput",
"DatasourceUnavailableError",
"DatasourceValidationError",
"datasource_catalogue",
"datasource_lifecycle",
"datasource_origins",
]
+455
View File
@@ -0,0 +1,455 @@
from __future__ import annotations
from collections import deque
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True, slots=True)
class DefinitionPort:
id: str
label: str
required: bool = True
multiple: bool = False
minimum_connections: int = 1
@dataclass(frozen=True, slots=True)
class DefinitionConfigField:
id: str
label: str
kind: str
required: bool = False
description: str | None = None
options: tuple[tuple[str, str], ...] = ()
@dataclass(frozen=True, slots=True)
class DefinitionNodeType:
type: str
category: str
label: str
description: str
icon: str
input_ports: tuple[DefinitionPort, ...] = ()
output_ports: tuple[DefinitionPort, ...] = (
DefinitionPort(id="output", label="Output"),
)
config_fields: tuple[DefinitionConfigField, ...] = ()
default_config: Mapping[str, Any] = field(default_factory=dict)
metadata: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DefinitionNode:
id: str
type: str
@dataclass(frozen=True, slots=True)
class DefinitionEdge:
id: str
source: str
target: str
source_port: str = "output"
target_port: str = "input"
@dataclass(frozen=True, slots=True)
class DefinitionNodeCountConstraint:
code: str
label: str
minimum: int = 0
maximum: int | None = None
node_types: tuple[str, ...] = ()
type_prefixes: tuple[str, ...] = ()
categories: tuple[str, ...] = ()
def matches(self, node: DefinitionNode, node_type: DefinitionNodeType | None) -> bool:
return (
node.type in self.node_types
or any(node.type.startswith(prefix) for prefix in self.type_prefixes)
or node_type is not None
and node_type.category in self.categories
)
@dataclass(frozen=True, slots=True)
class DefinitionGraphConstraints:
max_nodes: int = 100
max_edges: int = 200
allow_cycles: bool = False
require_connected: bool = True
node_counts: tuple[DefinitionNodeCountConstraint, ...] = ()
@dataclass(frozen=True, slots=True)
class DefinitionGraphLibrary:
id: str
version: str
category_labels: Mapping[str, str]
node_types: tuple[DefinitionNodeType, ...]
constraints: DefinitionGraphConstraints = field(default_factory=DefinitionGraphConstraints)
def node_type(self, type_id: str) -> DefinitionNodeType | None:
return next((item for item in self.node_types if item.type == type_id), None)
@dataclass(frozen=True, slots=True)
class DefinitionDiagnostic:
severity: str
code: str
message: str
node_id: str | None = None
field: str | None = None
def validate_definition_graph(
library: DefinitionGraphLibrary,
*,
nodes: Sequence[DefinitionNode],
edges: Sequence[DefinitionEdge],
) -> tuple[DefinitionDiagnostic, ...]:
if not nodes:
return (_error("graph.empty", "Add nodes before saving the definition."),)
constraints = library.constraints
diagnostics = _graph_size_diagnostics(
node_count=len(nodes),
edge_count=len(edges),
constraints=constraints,
)
node_by_id = {node.id: node for node in nodes}
definitions: dict[str, DefinitionNodeType] = {}
for item in library.node_types:
definitions.setdefault(item.type, item)
diagnostics.extend(_identifier_diagnostics(nodes=nodes, edges=edges))
incoming, undirected, topology_edges, edge_diagnostics = (
_validate_definition_edges(
edges=edges,
node_by_id=node_by_id,
definitions=definitions,
)
)
diagnostics.extend(edge_diagnostics)
diagnostics.extend(
_node_port_diagnostics(
nodes=nodes,
definitions=definitions,
incoming=incoming,
library_id=library.id,
)
)
diagnostics.extend(
_node_count_diagnostics(
nodes=nodes,
definitions=definitions,
constraints=constraints,
)
)
diagnostics.extend(
_topology_diagnostics(
nodes=nodes,
node_by_id=node_by_id,
topology_edges=topology_edges,
undirected=undirected,
constraints=constraints,
)
)
return tuple(diagnostics)
def _graph_size_diagnostics(
*,
node_count: int,
edge_count: int,
constraints: DefinitionGraphConstraints,
) -> list[DefinitionDiagnostic]:
diagnostics: list[DefinitionDiagnostic] = []
if node_count > constraints.max_nodes:
diagnostics.append(
_error(
"graph.node_limit",
f"Definitions are limited to {constraints.max_nodes:,} nodes.",
)
)
if edge_count > constraints.max_edges:
diagnostics.append(
_error(
"graph.edge_limit",
f"Definitions are limited to {constraints.max_edges:,} edges.",
)
)
return diagnostics
def _identifier_diagnostics(
*,
nodes: Sequence[DefinitionNode],
edges: Sequence[DefinitionEdge],
) -> list[DefinitionDiagnostic]:
diagnostics: list[DefinitionDiagnostic] = []
if len({node.id for node in nodes}) != len(nodes):
diagnostics.append(_error("graph.duplicate_node", "Node identifiers must be unique."))
if len({edge.id for edge in edges}) != len(edges):
diagnostics.append(_error("graph.duplicate_edge", "Edge identifiers must be unique."))
return diagnostics
def _validate_definition_edges(
*,
edges: Sequence[DefinitionEdge],
node_by_id: Mapping[str, DefinitionNode],
definitions: Mapping[str, DefinitionNodeType],
) -> tuple[
dict[str, list[DefinitionEdge]],
dict[str, set[str]],
list[DefinitionEdge],
list[DefinitionDiagnostic],
]:
incoming: dict[str, list[DefinitionEdge]] = {node_id: [] for node_id in node_by_id}
undirected: dict[str, set[str]] = {node_id: set() for node_id in node_by_id}
topology_edges: list[DefinitionEdge] = []
diagnostics: list[DefinitionDiagnostic] = []
for edge in edges:
structural_error = _edge_structure_diagnostic(edge, node_by_id)
if structural_error is not None:
diagnostics.append(structural_error)
continue
topology_edges.append(edge)
port_error = _edge_port_diagnostic(
edge,
source_definition=definitions.get(node_by_id[edge.source].type),
target_definition=definitions.get(node_by_id[edge.target].type),
)
if port_error is not None:
diagnostics.append(port_error)
continue
incoming[edge.target].append(edge)
undirected[edge.source].add(edge.target)
undirected[edge.target].add(edge.source)
return incoming, undirected, topology_edges, diagnostics
def _edge_structure_diagnostic(
edge: DefinitionEdge,
node_by_id: Mapping[str, DefinitionNode],
) -> DefinitionDiagnostic | None:
if edge.source not in node_by_id:
return _error(
"edge.unknown_source",
f"Edge {edge.id!r} references an unknown source node.",
)
if edge.target not in node_by_id:
return _error(
"edge.unknown_target",
f"Edge {edge.id!r} references an unknown target node.",
)
if edge.source == edge.target:
return _error(
"edge.self_reference",
"A node cannot connect to itself.",
node_id=edge.source,
)
return None
def _edge_port_diagnostic(
edge: DefinitionEdge,
*,
source_definition: DefinitionNodeType | None,
target_definition: DefinitionNodeType | None,
) -> DefinitionDiagnostic | None:
if source_definition is not None and edge.source_port not in {
port.id for port in source_definition.output_ports
}:
return _error(
"edge.unknown_source_port",
f"Node {edge.source!r} has no output port {edge.source_port!r}.",
node_id=edge.source,
)
if target_definition is not None and edge.target_port not in {
port.id for port in target_definition.input_ports
}:
return _error(
"edge.unknown_target_port",
f"Node {edge.target!r} has no input port {edge.target_port!r}.",
node_id=edge.target,
)
return None
def _node_port_diagnostics(
*,
nodes: Sequence[DefinitionNode],
definitions: Mapping[str, DefinitionNodeType],
incoming: Mapping[str, Sequence[DefinitionEdge]],
library_id: str,
) -> list[DefinitionDiagnostic]:
diagnostics: list[DefinitionDiagnostic] = []
for node in nodes:
definition = definitions.get(node.type)
if definition is None:
diagnostics.append(
_error(
"node.unsupported_type",
f"Node type {node.type!r} is not in the {library_id!r} library.",
node_id=node.id,
field="type",
)
)
continue
for port in definition.input_ports:
connections = [
edge for edge in incoming.get(node.id, ()) if edge.target_port == port.id
]
minimum = port.minimum_connections if port.required else 0
if len(connections) < minimum:
diagnostics.append(
_error(
"node.input_required",
(
f"{definition.label} requires {minimum} "
f"{port.label.lower()} connection"
f"{'' if minimum == 1 else 's'}."
),
node_id=node.id,
)
)
if not port.multiple and len(connections) > 1:
diagnostics.append(
_error(
"node.input_multiple",
f"{port.label} accepts only one connection.",
node_id=node.id,
)
)
return diagnostics
def _node_count_diagnostics(
*,
nodes: Sequence[DefinitionNode],
definitions: Mapping[str, DefinitionNodeType],
constraints: DefinitionGraphConstraints,
) -> list[DefinitionDiagnostic]:
diagnostics: list[DefinitionDiagnostic] = []
for count_constraint in constraints.node_counts:
count = sum(
count_constraint.matches(node, definitions.get(node.type))
for node in nodes
)
if count < count_constraint.minimum:
diagnostics.append(
_error(
count_constraint.code,
(
f"A definition needs at least {count_constraint.minimum} "
f"{count_constraint.label} node"
f"{'' if count_constraint.minimum == 1 else 's'}."
),
)
)
if count_constraint.maximum is not None and count > count_constraint.maximum:
diagnostics.append(
_error(
count_constraint.code,
(
f"A definition allows at most {count_constraint.maximum} "
f"{count_constraint.label} node"
f"{'' if count_constraint.maximum == 1 else 's'}."
),
)
)
return diagnostics
def _topology_diagnostics(
*,
nodes: Sequence[DefinitionNode],
node_by_id: Mapping[str, DefinitionNode],
topology_edges: Sequence[DefinitionEdge],
undirected: Mapping[str, set[str]],
constraints: DefinitionGraphConstraints,
) -> list[DefinitionDiagnostic]:
diagnostics: list[DefinitionDiagnostic] = []
_, cyclic = definition_topological_order(nodes, topology_edges)
if cyclic and not constraints.allow_cycles:
diagnostics.append(_error("graph.cycle", "Definition edges must form an acyclic graph."))
if constraints.require_connected and len(node_by_id) > 1:
connected = _connected_nodes(next(iter(node_by_id)), undirected)
if len(connected) != len(node_by_id):
diagnostics.append(
_error("graph.disconnected", "Every node must belong to one connected definition.")
)
return diagnostics
def definition_topological_order(
nodes: Sequence[DefinitionNode],
edges: Sequence[DefinitionEdge],
) -> tuple[tuple[str, ...], bool]:
node_ids = [node.id for node in nodes]
incoming_count = {node_id: 0 for node_id in node_ids}
outgoing: dict[str, list[str]] = {node_id: [] for node_id in node_ids}
for edge in edges:
if (
edge.source in outgoing
and edge.target in incoming_count
and edge.source != edge.target
):
outgoing[edge.source].append(edge.target)
incoming_count[edge.target] += 1
ready = deque(node_id for node_id in node_ids if incoming_count[node_id] == 0)
ordered: list[str] = []
while ready:
node_id = ready.popleft()
ordered.append(node_id)
for target in outgoing[node_id]:
incoming_count[target] -= 1
if incoming_count[target] == 0:
ready.append(target)
return tuple(ordered), len(ordered) != len(node_ids)
def _connected_nodes(start: str, adjacency: Mapping[str, set[str]]) -> set[str]:
seen: set[str] = set()
pending = [start]
while pending:
node_id = pending.pop()
if node_id in seen:
continue
seen.add(node_id)
pending.extend(adjacency.get(node_id, ()))
return seen
def _error(
code: str,
message: str,
*,
node_id: str | None = None,
field: str | None = None,
) -> DefinitionDiagnostic:
return DefinitionDiagnostic(
severity="error",
code=code,
message=message,
node_id=node_id,
field=field,
)
__all__ = [
"DefinitionConfigField",
"DefinitionDiagnostic",
"DefinitionEdge",
"DefinitionGraphConstraints",
"DefinitionGraphLibrary",
"DefinitionNode",
"DefinitionNodeCountConstraint",
"DefinitionNodeType",
"DefinitionPort",
"definition_topological_order",
"validate_definition_graph",
]
@@ -0,0 +1,402 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
CAPABILITY_DISTRIBUTION_LIST_SOURCE = "dist_lists.source"
CAPABILITY_DISTRIBUTION_LIST_EXPAND = "dist_lists.expand"
CAPABILITY_DISTRIBUTION_LIST_WRITER = "dist_lists.writer"
CAPABILITY_RECIPIENT_CHANNEL_FACTS = "addresses.channel_facts"
CAPABILITY_POLICY_DISTRIBUTION_CHANNELS = "policy.distribution_channels"
DistributionDefinitionKind = Literal["static", "parameterized", "dynamic", "template"]
DistributionEntryMode = Literal["include", "exclude", "override"]
DistributionEntryKind = Literal[
"address_contact",
"address_list",
"address_email",
"raw_email",
"raw_postal_address",
"internal_mail",
"portal",
"idm_identity",
"idm_group",
"organization_unit",
"function",
"effective_function_incumbent",
"dataflow_result",
"distribution_list",
]
DistributionChannel = Literal["email", "postal", "internal_mail", "portal"]
DistributionOutcome = Literal[
"usable",
"unresolved",
"invalid",
"suppressed",
"ambiguous",
"duplicate",
"policy_blocked",
"provider_unavailable",
"stale",
]
DistributionExplanationSeverity = Literal["info", "warning", "error"]
class DistributionListError(ValueError):
"""Stable base error for provider-neutral distribution-list operations."""
class DistributionListNotFoundError(DistributionListError):
pass
class DistributionListConflictError(DistributionListError):
pass
class DistributionListUnavailableError(DistributionListError):
pass
@dataclass(frozen=True, slots=True)
class DistributionSourceReference:
provider: str
resource_type: str
resource_id: str
revision: str | None = None
fingerprint: str | None = None
label: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionExplanation:
code: str
message: str
severity: DistributionExplanationSeverity = "warning"
provider: str | None = None
source: DistributionSourceReference | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionParameterDefinition:
key: str
value_type: Literal[
"string",
"integer",
"number",
"boolean",
"date",
"datetime",
"string_list",
]
label: str | None = None
required: bool = False
default: object | None = None
allowed_values: tuple[object, ...] = ()
minimum: float | None = None
maximum: float | None = None
pattern: str | None = None
description: str | None = None
@dataclass(frozen=True, slots=True)
class DistributionListEntryRef:
id: str
kind: DistributionEntryKind
mode: DistributionEntryMode
source: DistributionSourceReference
label: str | None = None
purpose: str | None = None
requested_channels: tuple[DistributionChannel, ...] = ()
effective_from: datetime | None = None
effective_until: datetime | None = None
order: int = 0
configuration: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionListSourceRef:
id: str
tenant_id: str
name: str
revision_id: str
revision: int
definition_hash: str
definition_kind: DistributionDefinitionKind = "static"
description: str | None = None
status: str = "active"
entry_count: int = 0
read_only: bool = False
stale: bool = False
parameters: tuple[DistributionParameterDefinition, ...] = ()
updated_at: datetime | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionExpansionLimits:
max_entries: int = 500
max_results: int = 5_000
max_depth: int = 8
max_provider_results: int = 2_000
@dataclass(frozen=True, slots=True)
class DistributionExpansionRequest:
list_id: str
revision: int | None = None
effective_at: datetime | None = None
purpose: str | None = None
requested_channels: tuple[DistributionChannel, ...] = ()
parameters: Mapping[str, object] = field(default_factory=dict)
preview: bool = False
freeze: bool = False
idempotency_key: str | None = None
limits: DistributionExpansionLimits = field(
default_factory=DistributionExpansionLimits
)
@dataclass(frozen=True, slots=True)
class DistributionChannelCandidate:
channel: DistributionChannel
target: str
target_key: str
status: DistributionOutcome = "usable"
contact_point_id: str | None = None
locale: str | None = None
preferred: bool = False
reason_code: str | None = None
explanation: str | None = None
source: DistributionSourceReference | None = None
decision_provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionRecipientRef:
recipient_key: str
display_name: str
status: DistributionOutcome
channels: tuple[DistributionChannelCandidate, ...] = ()
identity_id: str | None = None
account_id: str | None = None
contact_id: str | None = None
organization_unit_id: str | None = None
function_id: str | None = None
source_entry_ids: tuple[str, ...] = ()
explanations: tuple[DistributionExplanation, ...] = ()
attributes: Mapping[str, object] = field(default_factory=dict)
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionProviderEvidence:
provider: str
source: DistributionSourceReference
actual_revision: str | None = None
actual_fingerprint: str | None = None
stale: bool = False
generated_at: datetime | None = None
details: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionExpansionResult:
source: DistributionListSourceRef
request: DistributionExpansionRequest
recipients: tuple[DistributionRecipientRef, ...]
excluded: tuple[DistributionRecipientRef, ...] = ()
diagnostics: tuple[DistributionExplanation, ...] = ()
provider_evidence: tuple[DistributionProviderEvidence, ...] = ()
expansion_hash: str = ""
generated_at: datetime | None = None
snapshot_id: str | None = None
stale: bool = False
truncated: bool = False
@dataclass(frozen=True, slots=True)
class DistributionSnapshotRef:
id: str
tenant_id: str
list_id: str
revision_id: str
revision: int
expansion_hash: str
generated_at: datetime
effective_at: datetime
recipient_count: int
excluded_count: int
stale: bool
truncated: bool
request: Mapping[str, object] = field(default_factory=dict)
recipients: tuple[DistributionRecipientRef, ...] = ()
excluded: tuple[DistributionRecipientRef, ...] = ()
diagnostics: tuple[DistributionExplanation, ...] = ()
provider_evidence: tuple[DistributionProviderEvidence, ...] = ()
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionWriteDecision:
list_id: str | None
operation: str
allowed: bool
reason_code: str
explanation: str
read_only: bool = False
required_scopes: tuple[str, ...] = ()
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class RecipientChannelFactsRequest:
tenant_id: str
source: DistributionSourceReference
recipient_key: str
effective_at: datetime
purpose: str | None = None
requested_channels: tuple[DistributionChannel, ...] = ()
context: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class RecipientChannelFacts:
candidates: tuple[DistributionChannelCandidate, ...]
explanations: tuple[DistributionExplanation, ...] = ()
source_revision: str | None = None
source_fingerprint: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionChannelPolicyRequest:
tenant_id: str
list_id: str
purpose: str | None
effective_at: datetime
recipient: DistributionRecipientRef
candidate: DistributionChannelCandidate
context: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionChannelPolicyDecision:
allowed: bool
reason_code: str
explanation: str
source_path: tuple[Mapping[str, object], ...] = ()
requirements: tuple[str, ...] = ()
details: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class DistributionListSourceProvider(Protocol):
def list_sources(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
) -> Sequence[DistributionListSourceRef]: ...
def get_source(
self,
session: object,
principal: object,
*,
list_id: str,
revision: int | None = None,
) -> DistributionListSourceRef | None: ...
@runtime_checkable
class DistributionListExpansionProvider(Protocol):
def expand(
self,
session: object,
principal: object,
*,
request: DistributionExpansionRequest,
) -> DistributionExpansionResult: ...
def get_snapshot(
self,
session: object,
principal: object,
*,
snapshot_id: str,
) -> DistributionSnapshotRef | None: ...
@runtime_checkable
class DistributionListWriter(Protocol):
def explain_write(
self,
session: object,
principal: object,
*,
list_id: str | None,
operation: str,
) -> DistributionWriteDecision: ...
@runtime_checkable
class RecipientChannelFactsProvider(Protocol):
def resolve_channel_facts(
self,
session: object,
principal: object,
*,
request: RecipientChannelFactsRequest,
) -> RecipientChannelFacts: ...
@runtime_checkable
class DistributionChannelPolicyProvider(Protocol):
def resolve_distribution_channel(
self,
session: object,
principal: object,
*,
request: DistributionChannelPolicyRequest,
) -> DistributionChannelPolicyDecision: ...
def distribution_list_source_provider(
registry: object | None,
) -> DistributionListSourceProvider | None:
capability = _capability(registry, CAPABILITY_DISTRIBUTION_LIST_SOURCE)
return capability if isinstance(capability, DistributionListSourceProvider) else None
def distribution_list_expansion_provider(
registry: object | None,
) -> DistributionListExpansionProvider | None:
capability = _capability(registry, CAPABILITY_DISTRIBUTION_LIST_EXPAND)
return (
capability
if isinstance(capability, DistributionListExpansionProvider)
else None
)
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
__all__ = [name for name in globals() if name.startswith("CAPABILITY_") or name.startswith("Distribution") or name.startswith("Recipient") or name.startswith("distribution_")]
+692
View File
@@ -0,0 +1,692 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
CAPABILITY_ENCRYPTION_KEY_VAULT = "encryption.keyVault"
CAPABILITY_ENCRYPTION_CONTENT_PROTECTION = "encryption.contentProtection"
CAPABILITY_ENCRYPTION_RECOVERY = "encryption.recoveryCeremony"
CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT = "encryption.disablePreflight"
CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX = "encryption.keyMaterialProvider."
ENCRYPTION_CONTRACT_VERSION = "1"
ProtectionProfileKind = Literal[
"server_envelope",
"tenant_held",
"end_to_end",
]
KeyLifecycleState = Literal[
"active",
"rotation_pending",
"revoked",
"destruction_scheduled",
"destroyed",
"unavailable",
]
ProtectionState = Literal[
"active",
"migration_pending",
"migrating",
"migrated",
"decrypted",
"exported",
"destroyed",
"unavailable",
]
@dataclass(frozen=True, slots=True)
class KeyMaterialProvisionRequest:
tenant_id: str
vault_id: str
key_version: int
algorithm_suite: str
purpose: str
idempotency_key: str
provider_policy: Mapping[str, object] = field(default_factory=dict)
import_reference: str | None = None
contract_version: str = ENCRYPTION_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract(self.contract_version)
if self.key_version < 1:
raise ValueError("Key version must be positive")
for label, value in (
("tenant id", self.tenant_id),
("vault id", self.vault_id),
("algorithm suite", self.algorithm_suite),
("purpose", self.purpose),
("idempotency key", self.idempotency_key),
):
_require_text(value, label)
@dataclass(frozen=True, slots=True)
class KeyMaterialDescriptor:
provider_id: str
provider_key_ref: str
algorithm_suite: str
state: KeyLifecycleState
created_at: datetime
public_key_ref: str | None = None
imported: bool = False
exportable: bool = False
provider_version: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
contract_version: str = ENCRYPTION_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract(self.contract_version)
for label, value in (
("provider id", self.provider_id),
("provider key reference", self.provider_key_ref),
("algorithm suite", self.algorithm_suite),
):
_require_text(value, label)
_reject_secret_mapping(self.provenance, "Provider provenance")
@dataclass(frozen=True, slots=True)
class KeyVaultCreateRequest:
tenant_id: str
vault_id: str
name: str
provider_id: str
purpose: str
algorithm_suite: str
scope_type: str
scope_id: str | None
policy_ref: str
idempotency_key: str
recovery_quorum: int = 2
profile_kind: ProtectionProfileKind = "server_envelope"
import_reference: str | None = None
provider_policy: Mapping[str, object] = field(default_factory=dict)
contract_version: str = ENCRYPTION_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract(self.contract_version)
if self.recovery_quorum < 1:
raise ValueError("Recovery quorum must be positive")
for label, value in (
("tenant id", self.tenant_id),
("vault id", self.vault_id),
("name", self.name),
("provider id", self.provider_id),
("purpose", self.purpose),
("algorithm suite", self.algorithm_suite),
("scope type", self.scope_type),
("policy reference", self.policy_ref),
("idempotency key", self.idempotency_key),
):
_require_text(value, label)
_reject_secret_mapping(self.provider_policy, "Provider policy")
@dataclass(frozen=True, slots=True)
class KeyVersionRef:
tenant_id: str
vault_id: str
version: int
provider_id: str
provider_key_ref: str
algorithm_suite: str
state: KeyLifecycleState
created_at: datetime
activated_at: datetime | None = None
revoked_at: datetime | None = None
destruction_scheduled_at: datetime | None = None
destroyed_at: datetime | None = None
public_key_ref: str | None = None
imported: bool = False
exportable: bool = False
provenance: Mapping[str, object] = field(default_factory=dict)
contract_version: str = ENCRYPTION_CONTRACT_VERSION
@dataclass(frozen=True, slots=True)
class KeyVaultRef:
tenant_id: str
vault_id: str
name: str
provider_id: str
purpose: str
profile_kind: ProtectionProfileKind
scope_type: str
scope_id: str | None
policy_ref: str
recovery_quorum: int
state: str
revision: int
current_key: KeyVersionRef | None
created_at: datetime
updated_at: datetime
provenance: Mapping[str, object] = field(default_factory=dict)
contract_version: str = ENCRYPTION_CONTRACT_VERSION
@dataclass(frozen=True, slots=True)
class KeyRotationRequest:
tenant_id: str
vault_id: str
expected_revision: int
reason: str
policy_decision_ref: str
assurance_evidence_ref: str
idempotency_key: str
algorithm_suite: str | None = None
provider_policy: Mapping[str, object] = field(default_factory=dict)
contract_version: str = ENCRYPTION_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract(self.contract_version)
if self.expected_revision < 1:
raise ValueError("Expected revision must be positive")
for label, value in (
("tenant id", self.tenant_id),
("vault id", self.vault_id),
("reason", self.reason),
("policy decision reference", self.policy_decision_ref),
("assurance evidence reference", self.assurance_evidence_ref),
("idempotency key", self.idempotency_key),
):
_require_text(value, label)
_reject_secret_mapping(self.provider_policy, "Provider policy")
@dataclass(frozen=True, slots=True)
class KeyLifecycleRequest:
tenant_id: str
vault_id: str
key_version: int
expected_revision: int
reason: str
policy_decision_ref: str
assurance_evidence_ref: str
idempotency_key: str
effective_at: datetime | None = None
contract_version: str = ENCRYPTION_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract(self.contract_version)
if self.key_version < 1 or self.expected_revision < 1:
raise ValueError("Key version and expected revision must be positive")
for label, value in (
("tenant id", self.tenant_id),
("vault id", self.vault_id),
("reason", self.reason),
("policy decision reference", self.policy_decision_ref),
("assurance evidence reference", self.assurance_evidence_ref),
("idempotency key", self.idempotency_key),
):
_require_text(value, label)
@dataclass(frozen=True, slots=True)
class ContentProtectionEnvelope:
envelope_id: str
tenant_id: str
owner_module: str
resource_type: str
resource_id: str
profile_kind: ProtectionProfileKind
profile_id: str
provider_id: str
vault_id: str
key_version: int
algorithm_suite: str
ciphertext_ref: str
ciphertext_digest: str
authenticated_context_digest: str
state: ProtectionState
created_at: datetime
wrapped_key_refs: tuple[str, ...] = ()
source_envelope_id: str | None = None
migration_id: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
contract_version: str = ENCRYPTION_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract(self.contract_version)
if self.key_version < 1:
raise ValueError("Envelope key version must be positive")
for label, value in (
("envelope id", self.envelope_id),
("tenant id", self.tenant_id),
("owner module", self.owner_module),
("resource type", self.resource_type),
("resource id", self.resource_id),
("profile id", self.profile_id),
("provider id", self.provider_id),
("vault id", self.vault_id),
("algorithm suite", self.algorithm_suite),
("ciphertext reference", self.ciphertext_ref),
("ciphertext digest", self.ciphertext_digest),
("authenticated context digest", self.authenticated_context_digest),
):
_require_text(value, label)
_reject_secret_mapping(self.metadata, "Protection metadata")
@dataclass(frozen=True, slots=True)
class ProtectionRegistrationRequest:
envelope: ContentProtectionEnvelope
idempotency_key: str
policy_decision_ref: str
contract_version: str = ENCRYPTION_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract(self.contract_version)
_require_text(self.idempotency_key, "idempotency key")
_require_text(self.policy_decision_ref, "policy decision reference")
@dataclass(frozen=True, slots=True)
class ProtectionMigrationRequest:
tenant_id: str
envelope_id: str
target_provider_id: str
target_vault_id: str
target_key_version: int
target_algorithm_suite: str
mode: Literal["rewrap", "reencrypt", "decrypt", "export", "destroy"]
policy_decision_ref: str
assurance_evidence_ref: str
idempotency_key: str
contract_version: str = ENCRYPTION_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract(self.contract_version)
if self.target_key_version < 1:
raise ValueError("Target key version must be positive")
for label, value in (
("tenant id", self.tenant_id),
("envelope id", self.envelope_id),
("target provider id", self.target_provider_id),
("target vault id", self.target_vault_id),
("target algorithm suite", self.target_algorithm_suite),
("policy decision reference", self.policy_decision_ref),
("assurance evidence reference", self.assurance_evidence_ref),
("idempotency key", self.idempotency_key),
):
_require_text(value, label)
@dataclass(frozen=True, slots=True)
class ProtectionMigrationResult:
migration_id: str
state: Literal[
"requested",
"running",
"succeeded",
"rejected",
"outcome_unknown",
"reconciled",
]
source_envelope_id: str
target_envelope: ContentProtectionEnvelope | None = None
error: str | None = None
evidence_refs: tuple[str, ...] = ()
provenance: Mapping[str, object] = field(default_factory=dict)
contract_version: str = ENCRYPTION_CONTRACT_VERSION
@dataclass(frozen=True, slots=True)
class RecoveryRequest:
tenant_id: str
vault_id: str
reason: str
requested_scope: str
policy_decision_ref: str
assurance_evidence_ref: str
idempotency_key: str
expires_at: datetime
contract_version: str = ENCRYPTION_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract(self.contract_version)
for label, value in (
("tenant id", self.tenant_id),
("vault id", self.vault_id),
("reason", self.reason),
("requested scope", self.requested_scope),
("policy decision reference", self.policy_decision_ref),
("assurance evidence reference", self.assurance_evidence_ref),
("idempotency key", self.idempotency_key),
):
_require_text(value, label)
@dataclass(frozen=True, slots=True)
class RecoveryApprovalRequest:
tenant_id: str
recovery_id: str
decision: Literal["approve", "reject"]
reason: str
assurance_evidence_ref: str
expected_revision: int
idempotency_key: str
contract_version: str = ENCRYPTION_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract(self.contract_version)
if self.expected_revision < 1:
raise ValueError("Expected revision must be positive")
for label, value in (
("tenant id", self.tenant_id),
("recovery id", self.recovery_id),
("reason", self.reason),
("assurance evidence reference", self.assurance_evidence_ref),
("idempotency key", self.idempotency_key),
):
_require_text(value, label)
@dataclass(frozen=True, slots=True)
class RecoveryRef:
tenant_id: str
recovery_id: str
vault_id: str
state: Literal["pending", "approved", "rejected", "expired", "executed"]
requested_scope: str
quorum: int
approvals: int
rejections: int
revision: int
expires_at: datetime
policy_decision_ref: str
execution_ref: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
contract_version: str = ENCRYPTION_CONTRACT_VERSION
@dataclass(frozen=True, slots=True)
class DisablePreflightReport:
allowed: bool
protected_count: int
unresolved_count: int
state_counts: Mapping[str, int]
blocking_envelope_refs: tuple[str, ...]
required_actions: tuple[str, ...]
generated_at: datetime
contract_version: str = ENCRYPTION_CONTRACT_VERSION
@runtime_checkable
class EncryptionKeyMaterialProvider(Protocol):
def provision_key(
self,
session: object,
principal: object,
*,
request: KeyMaterialProvisionRequest,
) -> KeyMaterialDescriptor: ...
def schedule_key_destruction(
self,
session: object,
principal: object,
*,
provider_key_ref: str,
effective_at: datetime,
idempotency_key: str,
) -> KeyMaterialDescriptor: ...
def revoke_key(
self,
session: object,
principal: object,
*,
provider_key_ref: str,
reason: str,
idempotency_key: str,
) -> KeyMaterialDescriptor: ...
def key_status(
self,
session: object,
*,
provider_key_ref: str,
) -> KeyMaterialDescriptor | None: ...
@runtime_checkable
class EncryptionKeyVault(Protocol):
def create_vault(
self,
session: object,
principal: object,
*,
request: KeyVaultCreateRequest,
) -> KeyVaultRef: ...
def rotate_key(
self,
session: object,
principal: object,
*,
request: KeyRotationRequest,
) -> KeyVaultRef: ...
def revoke_key(
self,
session: object,
principal: object,
*,
request: KeyLifecycleRequest,
) -> KeyVaultRef: ...
def schedule_destruction(
self,
session: object,
principal: object,
*,
request: KeyLifecycleRequest,
) -> KeyVaultRef: ...
def get_vault(
self,
session: object,
*,
tenant_id: str,
vault_id: str,
) -> KeyVaultRef | None: ...
def reconcile_vault(
self,
session: object,
principal: object,
*,
tenant_id: str,
vault_id: str,
) -> KeyVaultRef: ...
@runtime_checkable
class ContentProtectionRegistry(Protocol):
def register_envelope(
self,
session: object,
principal: object,
*,
request: ProtectionRegistrationRequest,
) -> ContentProtectionEnvelope: ...
def request_migration(
self,
session: object,
principal: object,
*,
request: ProtectionMigrationRequest,
) -> ProtectionMigrationResult: ...
def reconcile_migration(
self,
session: object,
principal: object,
*,
migration_id: str,
) -> ProtectionMigrationResult: ...
def get_envelope(
self,
session: object,
principal: object,
*,
tenant_id: str,
envelope_id: str,
) -> ContentProtectionEnvelope | None: ...
def record_migration_outcome(
self,
session: object,
principal: object,
*,
migration_id: str,
state: Literal["succeeded", "rejected", "outcome_unknown"],
evidence_refs: tuple[str, ...],
target_envelope: ContentProtectionEnvelope | None = None,
error_code: str | None = None,
) -> ProtectionMigrationResult: ...
@runtime_checkable
class EncryptionRecoveryCeremony(Protocol):
def request_recovery(
self,
session: object,
principal: object,
*,
request: RecoveryRequest,
) -> RecoveryRef: ...
def decide_recovery(
self,
session: object,
principal: object,
*,
request: RecoveryApprovalRequest,
) -> RecoveryRef: ...
@runtime_checkable
class EncryptionDisablePreflight(Protocol):
def assess_disable(
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 100,
) -> DisablePreflightReport: ...
def key_material_provider(
registry: object | None,
provider_id: str,
) -> EncryptionKeyMaterialProvider | None:
capability = _capability(
registry,
f"{CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX}{provider_id}",
)
return capability if isinstance(capability, EncryptionKeyMaterialProvider) else None
def encryption_key_vault(registry: object | None) -> EncryptionKeyVault | None:
value = _capability(registry, CAPABILITY_ENCRYPTION_KEY_VAULT)
return value if isinstance(value, EncryptionKeyVault) else None
def content_protection_registry(
registry: object | None,
) -> ContentProtectionRegistry | None:
value = _capability(registry, CAPABILITY_ENCRYPTION_CONTENT_PROTECTION)
return value if isinstance(value, ContentProtectionRegistry) else None
def encryption_recovery_ceremony(
registry: object | None,
) -> EncryptionRecoveryCeremony | None:
value = _capability(registry, CAPABILITY_ENCRYPTION_RECOVERY)
return value if isinstance(value, EncryptionRecoveryCeremony) else None
def encryption_disable_preflight(
registry: object | None,
) -> EncryptionDisablePreflight | None:
value = _capability(registry, CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT)
return value if isinstance(value, EncryptionDisablePreflight) else None
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
def _validate_contract(value: str) -> None:
if value != ENCRYPTION_CONTRACT_VERSION:
raise ValueError("Unsupported encryption contract version")
def _require_text(value: str, label: str) -> None:
if not value.strip():
raise ValueError(f"{label.capitalize()} is required")
def _reject_secret_mapping(value: Mapping[str, object], label: str) -> None:
forbidden = {
"ciphertext",
"credential",
"key",
"key_material",
"password",
"plaintext",
"private_key",
"secret",
"token",
}
keys = {str(key).strip().casefold() for key in value}
if forbidden & keys:
raise ValueError(f"{label} must not contain secret material")
__all__ = [
"CAPABILITY_ENCRYPTION_CONTENT_PROTECTION",
"CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT",
"CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX",
"CAPABILITY_ENCRYPTION_KEY_VAULT",
"CAPABILITY_ENCRYPTION_RECOVERY",
"ContentProtectionEnvelope",
"ContentProtectionRegistry",
"DisablePreflightReport",
"EncryptionDisablePreflight",
"EncryptionKeyMaterialProvider",
"EncryptionKeyVault",
"EncryptionRecoveryCeremony",
"KeyLifecycleRequest",
"KeyMaterialDescriptor",
"KeyMaterialProvisionRequest",
"KeyRotationRequest",
"KeyVaultCreateRequest",
"KeyVaultRef",
"KeyVersionRef",
"ProtectionMigrationRequest",
"ProtectionMigrationResult",
"ProtectionRegistrationRequest",
"RecoveryApprovalRequest",
"RecoveryRef",
"RecoveryRequest",
"content_protection_registry",
"encryption_disable_preflight",
"encryption_key_vault",
"encryption_recovery_ceremony",
"key_material_provider",
]
+196 -2
View File
@@ -1,17 +1,25 @@
from __future__ import annotations
from collections import defaultdict
from collections.abc import Callable, Mapping
from collections.abc import Callable, Mapping, Sequence
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass, field
from datetime import datetime, timezone
import re
from typing import Any, Literal
from typing import Any, Literal, Protocol, runtime_checkable
import uuid
from sqlalchemy import event as sqlalchemy_event
from sqlalchemy.orm import Session
from govoplan_core.core.institutional import GovernedContextEnvelope
_TRACE_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
_CONSUMER_ID_RE = re.compile(r"^[a-z][a-z0-9_.:-]{0,127}$")
_PENDING_EVENTS_KEY = "govoplan.pending_platform_events"
CAPABILITY_PLATFORM_EVENT_OUTBOX = "platform.eventOutbox"
def new_event_id() -> str:
@@ -81,6 +89,7 @@ class PlatformEvent:
subject: EventObjectRef | None = None
resource: EventObjectRef | None = None
classification: EventClassification = "internal"
institutional_context: GovernedContextEnvelope | None = None
def to_dict(self) -> dict[str, Any]:
return {
@@ -96,10 +105,114 @@ class PlatformEvent:
"subject": self.subject.to_dict() if self.subject else None,
"resource": self.resource.to_dict() if self.resource else None,
"classification": self.classification,
"institutional_context": (
self.institutional_context.to_dict()
if self.institutional_context is not None
else None
),
}
EventHandler = Callable[[PlatformEvent], None]
DurableEventHandler = Callable[[PlatformEvent, str], None]
@dataclass(frozen=True, slots=True)
class DurableEventConsumer:
"""Allowlisted durable consumer with an explicit disclosure boundary."""
consumer_id: str
handler: DurableEventHandler
event_types: frozenset[str] = field(
default_factory=lambda: frozenset({"*"})
)
classifications: frozenset[EventClassification] = field(
default_factory=lambda: frozenset({"public", "internal"})
)
policy_decision_ref: str | None = None
def __post_init__(self) -> None:
if not _CONSUMER_ID_RE.fullmatch(self.consumer_id):
raise ValueError("Durable event consumer id is invalid")
if not self.event_types or any(
item != "*" and not _TRACE_ID_RE.fullmatch(item)
for item in self.event_types
):
raise ValueError(
"Durable event consumers require valid event-type allowlists"
)
invalid_classifications = set(self.classifications) - {
"public",
"internal",
"confidential",
"restricted",
}
if not self.classifications or invalid_classifications:
raise ValueError(
"Durable event consumer classifications are invalid"
)
if (
self.classifications & {"confidential", "restricted"}
and not normalize_trace_id(self.policy_decision_ref)
):
raise ValueError(
"Confidential or restricted event subscriptions require "
"an explicit policy decision reference"
)
def accepts(self, event: PlatformEvent) -> bool:
return (
event.classification in self.classifications
and (
"*" in self.event_types
or event.type in self.event_types
)
)
def delivery_key(self, event: PlatformEvent) -> str:
return f"{event.event_id}:{self.consumer_id}"
@runtime_checkable
class PlatformEventOutbox(Protocol):
def enqueue(self, session: object, event: PlatformEvent) -> object:
...
def dispatch_pending(
self,
session: object,
*,
consumers: Sequence[DurableEventConsumer] = (),
observer: EventHandler | None = None,
limit: int = 100,
) -> Mapping[str, int]:
...
def replay_delivery(
self,
session: object,
*,
event_id: str,
consumer_id: str,
operator_id: str,
reason: str,
) -> Mapping[str, object]:
...
def purge_terminal(
self,
session: object,
*,
before: datetime,
limit: int = 500,
) -> Mapping[str, int]:
...
def delivery_metrics(
self,
session: object,
) -> Mapping[str, object]:
...
def current_event_trace() -> EventTrace | None:
@@ -149,6 +262,7 @@ def ensure_event_trace(event: PlatformEvent) -> PlatformEvent:
subject=event.subject,
resource=event.resource,
classification=event.classification,
institutional_context=event.institutional_context,
)
@@ -189,5 +303,85 @@ def publish_platform_event(event: PlatformEvent) -> None:
platform_event_bus().publish(event)
def platform_event_outbox(
registry: object | None = None,
) -> PlatformEventOutbox | None:
if registry is None:
from govoplan_core.core.runtime import get_registry
registry = get_registry()
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
):
return None
capability = registry.capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
return capability if isinstance(capability, PlatformEventOutbox) else None
def emit_platform_event(
session: Session,
event: PlatformEvent,
*,
registry: object | None = None,
) -> None:
"""Persist an event with its transaction or publish it after commit.
The durable outbox is optional so reduced module combinations remain
usable. Without it, the event is kept on the SQLAlchemy session and only
delivered to the process-local bus after the outer transaction commits.
"""
traced = ensure_event_trace(event)
outbox = platform_event_outbox(registry)
if outbox is not None:
outbox.enqueue(session, traced)
return
transaction = (
session.get_nested_transaction()
or session.get_transaction()
or session.begin()
)
pending_by_transaction = session.info.setdefault(
_PENDING_EVENTS_KEY,
{},
)
pending_by_transaction.setdefault(transaction, []).append(
(platform_event_bus(), traced)
)
@sqlalchemy_event.listens_for(Session, "after_commit")
def _publish_committed_events(session: Session) -> None:
transaction = session.get_nested_transaction() or session.get_transaction()
if transaction is None:
return
pending_by_transaction = session.info.get(_PENDING_EVENTS_KEY)
if not isinstance(pending_by_transaction, dict):
return
pending = pending_by_transaction.pop(transaction, ())
parent = transaction.parent
if parent is not None:
pending_by_transaction.setdefault(parent, []).extend(pending)
else:
for bus, event in pending:
bus.publish(event)
if not pending_by_transaction:
session.info.pop(_PENDING_EVENTS_KEY, None)
@sqlalchemy_event.listens_for(Session, "after_rollback")
def _discard_rolled_back_events(session: Session) -> None:
transaction = session.get_nested_transaction() or session.get_transaction()
pending_by_transaction = session.info.get(_PENDING_EVENTS_KEY)
if transaction is None or not isinstance(pending_by_transaction, dict):
return
pending_by_transaction.pop(transaction, None)
if transaction.parent is None or not pending_by_transaction:
session.info.pop(_PENDING_EVENTS_KEY, None)
def _compact_dict(value: Mapping[str, Any]) -> dict[str, Any]:
return {key: item for key, item in value.items() if item is not None}
@@ -0,0 +1,175 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal
from urllib.parse import urlsplit
IntegrationMaturity = Literal[
"discover",
"link",
"search",
"read",
"publish",
"synchronize",
"migrate",
"replace",
]
SourceAuthorityMode = Literal[
"native_authoritative",
"external_authoritative",
"external_mirror",
"governed_sync",
"governance_overlay",
"linked_reference",
]
INTEGRATION_MATURITY_ORDER: tuple[IntegrationMaturity, ...] = (
"discover",
"link",
"search",
"read",
"publish",
"synchronize",
"migrate",
"replace",
)
SOURCE_AUTHORITY_MODES: tuple[SourceAuthorityMode, ...] = (
"native_authoritative",
"external_authoritative",
"external_mirror",
"governed_sync",
"governance_overlay",
"linked_reference",
)
class ExternalReferenceValidationError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class ExternalObjectReference:
"""Stable identity and provenance for an object owned by another system."""
system: str
object_type: str
object_id: str
maturity: IntegrationMaturity = "link"
authority_mode: SourceAuthorityMode = "linked_reference"
connector_id: str | None = None
canonical_url: str | None = None
version: str | None = None
etag: str | None = None
observed_at: datetime | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
for field_name in ("system", "object_type", "object_id"):
value = str(getattr(self, field_name) or "").strip()
if not value:
raise ExternalReferenceValidationError(
f"External reference {field_name} is required."
)
if len(value) > 255:
raise ExternalReferenceValidationError(
f"External reference {field_name} is limited to 255 characters."
)
object.__setattr__(self, field_name, value)
if self.maturity not in INTEGRATION_MATURITY_ORDER:
raise ExternalReferenceValidationError(
f"Unsupported integration maturity: {self.maturity!r}."
)
if self.authority_mode not in SOURCE_AUTHORITY_MODES:
raise ExternalReferenceValidationError(
f"Unsupported source-authority mode: {self.authority_mode!r}."
)
if (
self.authority_mode == "external_mirror"
and not self.supports("read")
):
raise ExternalReferenceValidationError(
"External-mirror references require read maturity or higher."
)
if (
self.authority_mode == "governed_sync"
and not self.supports("synchronize")
):
raise ExternalReferenceValidationError(
"Governed-sync references require synchronize maturity or higher."
)
if self.connector_id is not None:
connector_id = self.connector_id.strip()
if not connector_id:
raise ExternalReferenceValidationError(
"External reference connector_id cannot be blank."
)
object.__setattr__(self, "connector_id", connector_id)
if self.canonical_url is not None:
object.__setattr__(
self,
"canonical_url",
_validated_reference_url(self.canonical_url),
)
@property
def identity_key(self) -> str:
return f"{self.system}:{self.object_type}:{self.object_id}"
def supports(self, maturity: IntegrationMaturity) -> bool:
return integration_maturity_rank(self.maturity) >= integration_maturity_rank(
maturity
)
def to_dict(self) -> dict[str, object]:
return {
"system": self.system,
"object_type": self.object_type,
"object_id": self.object_id,
"maturity": self.maturity,
"authority_mode": self.authority_mode,
"connector_id": self.connector_id,
"canonical_url": self.canonical_url,
"version": self.version,
"etag": self.etag,
"observed_at": (
self.observed_at.isoformat() if self.observed_at is not None else None
),
"metadata": dict(self.metadata),
}
def integration_maturity_rank(maturity: IntegrationMaturity) -> int:
try:
return INTEGRATION_MATURITY_ORDER.index(maturity)
except ValueError as exc:
raise ExternalReferenceValidationError(
f"Unsupported integration maturity: {maturity!r}."
) from exc
def _validated_reference_url(value: str) -> str:
normalized = value.strip()
parsed = urlsplit(normalized)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ExternalReferenceValidationError(
"External reference URLs must use HTTP or HTTPS."
)
if parsed.username is not None or parsed.password is not None:
raise ExternalReferenceValidationError(
"External reference URLs must not contain credentials."
)
return normalized
__all__ = [
"ExternalObjectReference",
"ExternalReferenceValidationError",
"INTEGRATION_MATURITY_ORDER",
"IntegrationMaturity",
"SOURCE_AUTHORITY_MODES",
"SourceAuthorityMode",
"integration_maturity_rank",
]
+122
View File
@@ -0,0 +1,122 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
CAPABILITY_CONNECTORS_FEEDS = "connectors.feeds"
FeedFormat = Literal["rss", "atom"]
FeedVisibility = Literal["public", "tenant", "private"]
class FeedCapabilityError(ValueError):
"""Stable error raised by feed transport implementations."""
@dataclass(frozen=True, slots=True)
class FeedEntry:
id: str
title: str
url: str | None = None
summary: str | None = None
content: str | None = None
author: str | None = None
published_at: datetime | None = None
updated_at: datetime | None = None
categories: tuple[str, ...] = ()
enclosures: tuple[Mapping[str, object], ...] = ()
visibility: FeedVisibility = "public"
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class FeedDocument:
format: FeedFormat
title: str
source_url: str
entries: tuple[FeedEntry, ...]
description: str | None = None
home_url: str | None = None
language: str | None = None
updated_at: datetime | None = None
acquired_at: datetime | None = None
fresh_until: datetime | None = None
etag: str | None = None
last_modified: str | None = None
content_type: str | None = None
sha256: str = ""
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class FeedRenderRequest:
format: FeedFormat
title: str
feed_url: str
home_url: str
entries: tuple[FeedEntry, ...]
description: str | None = None
language: str | None = None
allowed_visibilities: frozenset[FeedVisibility] = frozenset({"public"})
@dataclass(frozen=True, slots=True)
class FeedRenderResult:
format: FeedFormat
content_type: str
body: bytes
included_entries: int
excluded_entries: int
@runtime_checkable
class FeedProvider(Protocol):
def fetch(
self,
url: str,
*,
timeout: float = 15,
max_entries: int = 2_000,
) -> FeedDocument:
...
def parse(
self,
content: bytes,
*,
source_url: str,
content_type: str | None = None,
max_entries: int = 2_000,
) -> FeedDocument:
...
def render(self, request: FeedRenderRequest) -> FeedRenderResult:
...
def feed_provider(registry: object | None) -> FeedProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(CAPABILITY_CONNECTORS_FEEDS)
):
return None
provider = registry.capability(CAPABILITY_CONNECTORS_FEEDS)
return provider if isinstance(provider, FeedProvider) else None
__all__ = [
"CAPABILITY_CONNECTORS_FEEDS",
"FeedCapabilityError",
"FeedDocument",
"FeedEntry",
"FeedFormat",
"FeedProvider",
"FeedRenderRequest",
"FeedRenderResult",
"FeedVisibility",
"feed_provider",
]
+321
View File
@@ -0,0 +1,321 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
CAPABILITY_IDENTITY_TRUST_DIRECTORY = "identity_trust.directory"
CAPABILITY_IDENTITY_TRUST_ASSURANCE = "identity_trust.assurance"
IDENTITY_TRUST_CONTRACT_VERSION = "1"
DeviceKeyPurpose = Literal["encryption", "signing", "encryption_and_signing"]
DeviceKeyStatus = Literal["active", "revoked", "expired"]
TrustSubjectKind = Literal[
"identity",
"account",
"function",
"postbox",
"external_recipient",
]
_PRIVATE_JWK_FIELDS = frozenset({"d", "p", "q", "dp", "dq", "qi", "oth", "k"})
@dataclass(frozen=True, slots=True)
class DeviceKeyRegistration:
tenant_id: str
identity_id: str
account_id: str
device_id: str
key_id: str
algorithm: str
public_jwk: Mapping[str, object]
purpose: DeviceKeyPurpose = "encryption"
assurance_level: str = "software"
attestation_ref: str | None = None
expires_at: datetime | None = None
idempotency_key: str = ""
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract(self.contract_version)
for label, value in (
("tenant id", self.tenant_id),
("identity id", self.identity_id),
("account id", self.account_id),
("device id", self.device_id),
("key id", self.key_id),
("algorithm", self.algorithm),
("idempotency key", self.idempotency_key),
):
_require_text(value, label)
if not self.public_jwk or _PRIVATE_JWK_FIELDS & set(self.public_jwk):
raise ValueError("Only a bounded public JWK may be registered")
@dataclass(frozen=True, slots=True)
class DeviceKeyRef:
tenant_id: str
identity_id: str
account_id: str
device_id: str
key_id: str
algorithm: str
public_jwk: Mapping[str, object]
purpose: DeviceKeyPurpose
assurance_level: str
status: DeviceKeyStatus
epoch: int
registered_at: datetime
attestation_ref: str | None = None
expires_at: datetime | None = None
revoked_at: datetime | None = None
revocation_reason: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
@dataclass(frozen=True, slots=True)
class KeyEpochRotationRequest:
tenant_id: str
subject_kind: TrustSubjectKind
subject_id: str
reason: str
access_decision_ref: str
idempotency_key: str
history_policy: str = "all_retained"
previous_epoch: int | None = None
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract(self.contract_version)
for label, value in (
("tenant id", self.tenant_id),
("subject id", self.subject_id),
("reason", self.reason),
("access decision reference", self.access_decision_ref),
("idempotency key", self.idempotency_key),
):
_require_text(value, label)
@dataclass(frozen=True, slots=True)
class KeyEpochRef:
tenant_id: str
subject_kind: TrustSubjectKind
subject_id: str
epoch: int
state: Literal["active", "superseded", "revoked"]
history_policy: str
effective_at: datetime
previous_epoch: int | None = None
reason: str | None = None
access_decision_ref: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
@dataclass(frozen=True, slots=True)
class KeyAccessRequest:
tenant_id: str
account_id: str
device_key_id: str
subject_kind: TrustSubjectKind
subject_id: str
key_epoch: int
access_decision_ref: str
purpose: str
requested_at: datetime
function_assignment_id: str | None = None
delegation_id: str | None = None
resource_ref: str | None = None
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract(self.contract_version)
if self.key_epoch < 1:
raise ValueError("Key epoch must be positive")
for label, value in (
("tenant id", self.tenant_id),
("account id", self.account_id),
("device key id", self.device_key_id),
("subject id", self.subject_id),
("access decision reference", self.access_decision_ref),
("purpose", self.purpose),
):
_require_text(value, label)
@dataclass(frozen=True, slots=True)
class KeyAccessDecision:
allowed: bool
decision_ref: str
reason: str
device_key: DeviceKeyRef | None = None
epoch: KeyEpochRef | None = None
audit_event_ref: str | None = None
requirements: tuple[str, ...] = ()
provenance: Mapping[str, object] = field(default_factory=dict)
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
@dataclass(frozen=True, slots=True)
class AssuranceCheckRequest:
tenant_id: str
account_id: str
purpose: str
minimum_level: str
evidence_ref: str
evaluated_at: datetime
maximum_age_seconds: int = 300
device_key_id: str | None = None
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract(self.contract_version)
if self.maximum_age_seconds < 1:
raise ValueError("Assurance maximum age must be positive")
for label, value in (
("tenant id", self.tenant_id),
("account id", self.account_id),
("purpose", self.purpose),
("minimum level", self.minimum_level),
("evidence reference", self.evidence_ref),
):
_require_text(value, label)
@dataclass(frozen=True, slots=True)
class AssuranceDecision:
allowed: bool
reason: str
assurance_level: str | None = None
evidence_ref: str | None = None
verified_at: datetime | None = None
expires_at: datetime | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
@runtime_checkable
class IdentityTrustDirectory(Protocol):
def register_device_key(
self,
session: object,
principal: object,
*,
request: DeviceKeyRegistration,
) -> DeviceKeyRef: ...
def revoke_device_key(
self,
session: object,
principal: object,
*,
tenant_id: str,
key_id: str,
expected_epoch: int,
reason: str,
) -> DeviceKeyRef: ...
def list_device_keys(
self,
session: object,
principal: object,
*,
tenant_id: str,
account_id: str,
active_only: bool = True,
) -> tuple[DeviceKeyRef, ...]: ...
def rotate_epoch(
self,
session: object,
principal: object,
*,
request: KeyEpochRotationRequest,
) -> KeyEpochRef: ...
def resolve_epoch(
self,
session: object,
*,
tenant_id: str,
subject_kind: TrustSubjectKind,
subject_id: str,
epoch: int | None = None,
) -> KeyEpochRef | None: ...
def decide_key_access(
self,
session: object,
principal: object,
*,
request: KeyAccessRequest,
) -> KeyAccessDecision: ...
@runtime_checkable
class IdentityTrustAssurance(Protocol):
def verify_assurance(
self,
session: object,
principal: object,
*,
request: AssuranceCheckRequest,
) -> AssuranceDecision: ...
def identity_trust_directory(
registry: object | None,
) -> IdentityTrustDirectory | None:
capability = _capability(registry, CAPABILITY_IDENTITY_TRUST_DIRECTORY)
return capability if isinstance(capability, IdentityTrustDirectory) else None
def identity_trust_assurance(
registry: object | None,
) -> IdentityTrustAssurance | None:
capability = _capability(registry, CAPABILITY_IDENTITY_TRUST_ASSURANCE)
return capability if isinstance(capability, IdentityTrustAssurance) else None
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
def _validate_contract(value: str) -> None:
if value != IDENTITY_TRUST_CONTRACT_VERSION:
raise ValueError("Unsupported identity-trust contract version")
def _require_text(value: str, label: str) -> None:
if not value.strip():
raise ValueError(f"{label.capitalize()} is required")
__all__ = [
"AssuranceCheckRequest",
"AssuranceDecision",
"CAPABILITY_IDENTITY_TRUST_ASSURANCE",
"CAPABILITY_IDENTITY_TRUST_DIRECTORY",
"DeviceKeyRef",
"DeviceKeyRegistration",
"IdentityTrustAssurance",
"IdentityTrustDirectory",
"KeyAccessDecision",
"KeyAccessRequest",
"KeyEpochRef",
"KeyEpochRotationRequest",
"identity_trust_assurance",
"identity_trust_directory",
]
+91 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
@@ -8,6 +8,8 @@ from typing import Literal, Protocol, runtime_checkable
IDM_MODULE_ID = "idm"
CAPABILITY_IDM_DIRECTORY = "idm.directory"
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS = "idm.function_assignments"
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE = "idm.assignment_lifecycle"
IdmStatus = Literal["active", "inactive", "suspended"]
OrganizationFunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
@@ -30,6 +32,18 @@ class OrganizationFunctionAssignmentRef:
status: IdmStatus = "active"
@dataclass(frozen=True, slots=True)
class OrganizationFunctionIncumbencyRef:
tenant_id: str
function_id: str
assignments: tuple[OrganizationFunctionAssignmentRef, ...] = ()
function_active: bool = True
@property
def vacant(self) -> bool:
return not self.assignments
@runtime_checkable
class IdmDirectory(Protocol):
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
@@ -40,6 +54,7 @@ class IdmDirectory(Protocol):
identity_id: str,
*,
tenant_id: str | None = None,
effective_at: datetime | None = None,
) -> Sequence[OrganizationFunctionAssignmentRef]:
...
@@ -48,5 +63,80 @@ class IdmDirectory(Protocol):
account_id: str,
*,
tenant_id: str | None = None,
effective_at: datetime | None = None,
) -> Sequence[OrganizationFunctionAssignmentRef]:
...
def organization_function_assignments_for_identities(
self,
identity_ids: Sequence[str],
*,
tenant_id: str | None = None,
effective_at: datetime | None = None,
) -> Mapping[str, Sequence[OrganizationFunctionAssignmentRef]]:
...
def organization_function_assignments_for_accounts(
self,
account_ids: Sequence[str],
*,
tenant_id: str | None = None,
effective_at: datetime | None = None,
) -> Mapping[str, Sequence[OrganizationFunctionAssignmentRef]]:
...
@runtime_checkable
class IdmFunctionAssignmentDirectory(Protocol):
"""Reverse lookup for effective incumbency and vacancy decisions."""
def organization_function_assignments_for_function(
self,
function_id: str,
*,
tenant_id: str | None = None,
effective_at: datetime | None = None,
) -> Sequence[OrganizationFunctionAssignmentRef]:
...
def organization_function_incumbencies(
self,
function_ids: Sequence[str],
*,
tenant_id: str,
effective_at: datetime | None = None,
) -> Mapping[str, OrganizationFunctionIncumbencyRef]:
...
@runtime_checkable
class IdmAssignmentLifecycle(Protocol):
"""Worker boundary for time-driven function-assignment transitions."""
def process_expired(
self,
session: object,
*,
tenant_id: str | None = None,
effective_at: datetime | None = None,
limit: int = 100,
) -> Mapping[str, object]:
...
def idm_assignment_lifecycle(
registry: object | None,
) -> IdmAssignmentLifecycle | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE)
):
return None
capability = registry.capability(CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE)
return (
capability
if isinstance(capability, IdmAssignmentLifecycle)
else None
)
+567 -46
View File
@@ -3,8 +3,10 @@ from __future__ import annotations
import base64
import json
import os
import re
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from cryptography.fernet import Fernet
@@ -88,7 +90,9 @@ class _ConfigIssueCollector:
def add(self, level: ConfigIssueLevel, key: str, message: str, action: str) -> None:
if self.strict and level == "warning":
level = "error"
self.issues.append(ConfigIssue(level=level, key=key, message=message, action=action))
self.issues.append(
ConfigIssue(level=level, key=key, message=message, action=action)
)
_LOCAL_PROFILES = {"dev", "local", "local-dev", "test"}
@@ -118,9 +122,15 @@ def generate_master_key() -> str:
return Fernet.generate_key().decode("ascii")
def env_template(*, profile: str = "self-hosted", generate_secrets: bool = False) -> str:
def env_template(
*, profile: str = "self-hosted", generate_secrets: bool = False
) -> str:
clean_profile = normalize_install_profile(profile)
master_key = generate_master_key() if generate_secrets else "<generate-with-govoplan-config-env-template-generate-secrets>"
master_key = (
generate_master_key()
if generate_secrets
else "<generate-with-govoplan-config-env-template-generate-secrets>"
)
if clean_profile == "production-like":
return _production_like_env_template(master_key)
return _self_hosted_env_template(master_key)
@@ -142,13 +152,19 @@ def validate_runtime_configuration(
_validate_async_and_auth_settings(env, runtime, collector)
_validate_cors_settings(env, runtime, collector)
_validate_file_storage_settings(env, runtime, collector)
_validate_shared_state_settings(env, collector)
_validate_outbound_connector_policy(env, runtime, collector)
_validate_module_catalog_trust(env, runtime, collector)
return ConfigValidationResult(profile=runtime.name, issues=tuple(collector.issues))
def _runtime_profile(env: Mapping[str, str], *, profile: str | None) -> _RuntimeProfile:
clean_profile = normalize_install_profile(profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV"))
production = clean_profile in _PRODUCTION_PROFILES or env.get("APP_ENV", "").strip().lower() in {"prod", "production"}
clean_profile = normalize_install_profile(
profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV")
)
production = clean_profile in _PRODUCTION_PROFILES or env.get(
"APP_ENV", ""
).strip().lower() in {"prod", "production"}
production_like = production or clean_profile in _PRODUCTION_LIKE_PROFILES
return _RuntimeProfile(
name=clean_profile,
@@ -158,105 +174,537 @@ def _runtime_profile(env: Mapping[str, str], *, profile: str | None) -> _Runtime
)
def _validate_app_env(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
def _validate_app_env(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
app_env = _clean(env.get("APP_ENV"))
if not app_env and runtime.production_like:
collector.add("error", "APP_ENV", "APP_ENV is missing for a production-like install.", "Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.")
collector.add(
"error",
"APP_ENV",
"APP_ENV is missing for a production-like install.",
"Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.",
)
elif app_env.lower() in {"dev", "test", "local"} and runtime.production_like:
collector.add("error", "APP_ENV", f"APP_ENV={app_env!r} is not valid for profile {runtime.name!r}.", "Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.")
collector.add(
"error",
"APP_ENV",
f"APP_ENV={app_env!r} is not valid for profile {runtime.name!r}.",
"Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.",
)
def _validate_database_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
def _validate_database_settings(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
database_url = _clean(env.get("DATABASE_URL"))
if not database_url:
collector.add("error", "DATABASE_URL", "DATABASE_URL is missing.", "Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.")
collector.add(
"error",
"DATABASE_URL",
"DATABASE_URL is missing.",
"Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.",
)
return
backend = _database_backend(database_url)
if backend is None:
collector.add("error", "DATABASE_URL", "DATABASE_URL is not a valid SQLAlchemy URL.", "Use a value like postgresql+psycopg://user:password@host:5432/database.")
collector.add(
"error",
"DATABASE_URL",
"DATABASE_URL is not a valid SQLAlchemy URL.",
"Use a value like postgresql+psycopg://user:password@host:5432/database.",
)
return
if backend == "sqlite" and runtime.production_like:
collector.add("error", "DATABASE_URL", "SQLite is only supported for disposable local development.", "Use PostgreSQL for production-like and self-hosted installs.")
collector.add(
"error",
"DATABASE_URL",
"SQLite is only supported for disposable local development.",
"Use PostgreSQL for production-like and self-hosted installs.",
)
elif backend != "postgresql" and runtime.production:
collector.add("warning", "DATABASE_URL", f"Database backend {backend!r} is not the preferred production target.", "Use PostgreSQL unless this deployment has an explicit support decision.")
collector.add(
"warning",
"DATABASE_URL",
f"Database backend {backend!r} is not the preferred production target.",
"Use PostgreSQL unless this deployment has an explicit support decision.",
)
if backend == "postgresql" and not _clean(env.get("GOVOPLAN_DATABASE_URL_PGTOOLS")):
collector.add("warning", "GOVOPLAN_DATABASE_URL_PGTOOLS", "PostgreSQL backup/restore tools URL is missing.", "Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.")
collector.add(
"warning",
"GOVOPLAN_DATABASE_URL_PGTOOLS",
"PostgreSQL backup/restore tools URL is missing.",
"Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.",
)
def _validate_master_key(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
def _validate_master_key(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
master_key = _clean(env.get("MASTER_KEY_B64"))
if not master_key and not runtime.local:
collector.add("error", "MASTER_KEY_B64", "MASTER_KEY_B64 is required outside local dev/test.", "Generate a Fernet key with `govoplan-config env-template --generate-secrets` or `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` and store it in deployment secrets.")
collector.add(
"error",
"MASTER_KEY_B64",
"MASTER_KEY_B64 is required outside local dev/test.",
"Generate a Fernet key with `govoplan-config env-template --generate-secrets` or `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` and store it in deployment secrets.",
)
return
if not master_key:
return
error = _master_key_error(master_key)
if error:
collector.add("error", "MASTER_KEY_B64", error, "Replace MASTER_KEY_B64 with a Fernet key or base64-encoded 32-byte key.")
collector.add(
"error",
"MASTER_KEY_B64",
error,
"Replace MASTER_KEY_B64 with a Fernet key or base64-encoded 32-byte key.",
)
elif "change-me" in master_key.lower() or "generate" in master_key.lower():
collector.add("error", "MASTER_KEY_B64", "MASTER_KEY_B64 still looks like a placeholder.", "Generate a real deployment key and store it outside git.")
collector.add(
"error",
"MASTER_KEY_B64",
"MASTER_KEY_B64 still looks like a placeholder.",
"Generate a real deployment key and store it outside git.",
)
def _validate_enabled_modules(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
def _validate_enabled_modules(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
enabled_modules = _csv(env.get("ENABLED_MODULES"))
if not enabled_modules and runtime.production_like:
collector.add("error", "ENABLED_MODULES", "ENABLED_MODULES is missing.", "Set ENABLED_MODULES explicitly so startup module composition is intentional.")
collector.add(
"error",
"ENABLED_MODULES",
"ENABLED_MODULES is missing.",
"Set ENABLED_MODULES explicitly so startup module composition is intentional.",
)
elif "access" not in enabled_modules and runtime.production_like:
collector.add("error", "ENABLED_MODULES", "The access module is not enabled.", "Include `access` unless this deployment has a replacement auth/principal provider.")
collector.add(
"error",
"ENABLED_MODULES",
"The access module is not enabled.",
"Include `access` unless this deployment has a replacement auth/principal provider.",
)
elif enabled_modules and "admin" not in enabled_modules and runtime.production_like:
collector.add("warning", "ENABLED_MODULES", "The admin module is not enabled.", "Keep `admin` enabled for operator UI unless this is a deliberately headless install.")
collector.add(
"warning",
"ENABLED_MODULES",
"The admin module is not enabled.",
"Keep `admin` enabled for operator UI unless this is a deliberately headless install.",
)
def _validate_async_and_auth_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
def _validate_async_and_auth_settings(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
if _truthy(env.get("CELERY_ENABLED")) and not _clean(env.get("REDIS_URL")):
collector.add("error", "REDIS_URL", "CELERY_ENABLED=true but REDIS_URL is missing.", "Set REDIS_URL to the Redis broker/result backend used by workers.")
collector.add(
"error",
"REDIS_URL",
"CELERY_ENABLED=true but REDIS_URL is missing.",
"Set REDIS_URL to the Redis broker/result backend used by workers.",
)
if runtime.production and _truthy(env.get("DEV_BOOTSTRAP_ENABLED")):
collector.add("error", "DEV_BOOTSTRAP_ENABLED", "Development bootstrap is enabled in production.", "Set DEV_BOOTSTRAP_ENABLED=false and create first administrators through the controlled bootstrap path.")
collector.add(
"error",
"DEV_BOOTSTRAP_ENABLED",
"Development bootstrap is enabled in production.",
"Set DEV_BOOTSTRAP_ENABLED=false and create first administrators through the controlled bootstrap path.",
)
if runtime.production and not _truthy(env.get("AUTH_COOKIE_SECURE")):
collector.add("error", "AUTH_COOKIE_SECURE", "Secure auth cookies are disabled for production.", "Set AUTH_COOKIE_SECURE=true behind HTTPS.")
collector.add(
"error",
"AUTH_COOKIE_SECURE",
"Secure auth cookies are disabled for production.",
"Set AUTH_COOKIE_SECURE=true behind HTTPS.",
)
def _validate_cors_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
def _validate_cors_settings(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
cors_origins = _csv(env.get("CORS_ORIGINS"))
if runtime.production_like and not cors_origins:
collector.add("error", "CORS_ORIGINS", "CORS_ORIGINS is missing.", "Set CORS_ORIGINS to the exact WebUI origin or origins.")
collector.add(
"error",
"CORS_ORIGINS",
"CORS_ORIGINS is missing.",
"Set CORS_ORIGINS to the exact WebUI origin or origins.",
)
elif "*" in cors_origins and runtime.production_like:
collector.add("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.")
collector.add(
"error",
"CORS_ORIGINS",
"Wildcard CORS is not allowed for production-like installs.",
"Replace `*` with exact HTTPS/WebUI origins.",
)
elif runtime.production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
collector.add("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.")
collector.add(
"warning",
"CORS_ORIGINS",
"CORS_ORIGINS still contains only local development origins.",
"Set CORS_ORIGINS to the deployed WebUI origin.",
)
trusted_hosts = _csv(env.get("GOVOPLAN_TRUSTED_HOSTS"))
if runtime.production_like and not trusted_hosts:
collector.add(
"error",
"GOVOPLAN_TRUSTED_HOSTS",
"Trusted HTTP hosts are not configured.",
"Set GOVOPLAN_TRUSTED_HOSTS to the exact API host names accepted by this deployment.",
)
elif "*" in trusted_hosts and runtime.production_like:
collector.add(
"error",
"GOVOPLAN_TRUSTED_HOSTS",
"Wildcard trusted hosts are not allowed for production-like installs.",
"Replace `*` with exact host names or narrowly scoped `*.example.org` entries.",
)
forwarded_allow_ips = _csv(env.get("FORWARDED_ALLOW_IPS"))
if runtime.production_like and "*" in forwarded_allow_ips:
collector.add(
"error",
"FORWARDED_ALLOW_IPS",
"Proxy headers must not be trusted from every address.",
"Set FORWARDED_ALLOW_IPS to the reverse proxy address or network passed to Uvicorn.",
)
def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
def _validate_file_storage_settings(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
storage_backend = _clean(env.get("FILE_STORAGE_BACKEND")) or "local"
deployment_managed_raw = _clean(
env.get("FILE_STORAGE_S3_DEPLOYMENT_MANAGED")
).lower()
endpoint_trusted_raw = _clean(env.get("FILE_STORAGE_S3_ENDPOINT_TRUSTED")).lower()
if deployment_managed_raw and deployment_managed_raw not in {
"true",
"false",
"1",
"0",
"yes",
"no",
"on",
"off",
}:
collector.add(
"error",
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
"Managed S3 trust must be an explicit boolean.",
"Set FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false, or let the supported installer manage Garage.",
)
deployment_managed = _truthy(deployment_managed_raw)
if endpoint_trusted_raw and endpoint_trusted_raw not in {
"true",
"false",
"1",
"0",
"yes",
"no",
"on",
"off",
}:
collector.add(
"error",
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
"External S3 endpoint trust must be an explicit boolean.",
"Set it only for a deployment-controlled HTTPS storage origin.",
)
endpoint_trusted = _truthy(endpoint_trusted_raw)
if storage_backend == "local":
if deployment_managed or endpoint_trusted:
collector.add(
"error",
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
"S3 endpoint trust cannot be enabled for local file storage.",
"Disable both S3 trust settings while FILE_STORAGE_BACKEND=local.",
)
_validate_local_file_storage(env, runtime, collector)
elif storage_backend == "s3":
_validate_s3_file_storage(env, collector)
_validate_s3_file_storage(
env,
collector,
deployment_managed=deployment_managed,
endpoint_trusted=endpoint_trusted,
)
else:
collector.add("error", "FILE_STORAGE_BACKEND", f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.", "Use `local` or `s3`.")
collector.add(
"error",
"FILE_STORAGE_BACKEND",
f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.",
"Use `local` or `s3`.",
)
def _validate_local_file_storage(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
def _validate_local_file_storage(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
if not _clean(env.get("FILE_STORAGE_LOCAL_ROOT")) and runtime.production_like:
collector.add("error", "FILE_STORAGE_LOCAL_ROOT", "Local file storage root is missing.", "Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.")
collector.add(
"error",
"FILE_STORAGE_LOCAL_ROOT",
"Local file storage root is missing.",
"Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.",
)
elif runtime.production:
collector.add("warning", "FILE_STORAGE_BACKEND", "Production is configured for local file storage.", "Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.")
collector.add(
"warning",
"FILE_STORAGE_BACKEND",
"Production is configured for local file storage.",
"Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.",
)
def _validate_s3_file_storage(env: Mapping[str, str], collector: _ConfigIssueCollector) -> None:
for key in ("FILE_STORAGE_S3_ENDPOINT_URL", "FILE_STORAGE_S3_REGION", "FILE_STORAGE_S3_ACCESS_KEY_ID", "FILE_STORAGE_S3_SECRET_ACCESS_KEY", "FILE_STORAGE_S3_BUCKET"):
def _validate_s3_file_storage(
env: Mapping[str, str],
collector: _ConfigIssueCollector,
*,
deployment_managed: bool,
endpoint_trusted: bool,
) -> None:
for key in (
"FILE_STORAGE_S3_ENDPOINT_URL",
"FILE_STORAGE_S3_REGION",
"FILE_STORAGE_S3_ACCESS_KEY_ID",
"FILE_STORAGE_S3_SECRET_ACCESS_KEY",
"FILE_STORAGE_S3_BUCKET",
):
if not _clean(env.get(key)):
collector.add("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
collector.add(
"error",
key,
f"{key} is required when FILE_STORAGE_BACKEND=s3.",
"Configure all FILE_STORAGE_S3_* settings through deployment secrets.",
)
if (
deployment_managed
and _clean(env.get("FILE_STORAGE_S3_ENDPOINT_URL")) != "http://garage:3900"
):
collector.add(
"error",
"FILE_STORAGE_S3_ENDPOINT_URL",
"Installer-managed S3 trust is restricted to http://garage:3900.",
"Use the exact managed Garage endpoint or disable FILE_STORAGE_S3_DEPLOYMENT_MANAGED.",
)
if deployment_managed and endpoint_trusted:
collector.add(
"error",
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
"Managed Garage trust and external endpoint trust are mutually exclusive.",
"Use installer-managed Garage trust or one explicit external endpoint.",
)
if not deployment_managed and not endpoint_trusted:
collector.add(
"error",
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
"External S3 storage requires an explicit deployment trust decision.",
"Set FILE_STORAGE_S3_ENDPOINT_TRUSTED=true only for a deployment-controlled HTTPS origin.",
)
endpoint = _clean(env.get("FILE_STORAGE_S3_ENDPOINT_URL"))
if endpoint_trusted and not endpoint.lower().startswith("https://"):
collector.add(
"error",
"FILE_STORAGE_S3_ENDPOINT_URL",
"Deployment-trusted external S3 storage must use HTTPS.",
"Use an HTTPS storage origin with certificate verification.",
)
def _validate_module_catalog_trust(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG"))
def _validate_shared_state_settings(
env: Mapping[str, str],
collector: _ConfigIssueCollector,
) -> None:
state_profile = (_clean(env.get("GOVOPLAN_STATE_PROFILE")) or "local").lower()
if state_profile not in {"local", "host-shared", "shared"}:
collector.add(
"error",
"GOVOPLAN_STATE_PROFILE",
f"Unsupported state profile {state_profile!r}.",
"Use `local` for one process per role, `host-shared` for one Compose host, or `shared` for a multi-host stateless tier.",
)
return
try:
api_replicas = int(_clean(env.get("GOVOPLAN_EXPECTED_API_REPLICAS")) or "1")
worker_replicas = int(
_clean(env.get("GOVOPLAN_EXPECTED_WORKER_REPLICAS")) or "0"
)
except ValueError:
collector.add(
"error",
"GOVOPLAN_EXPECTED_API_REPLICAS",
"Expected replica counts must be integers.",
"Set GOVOPLAN_EXPECTED_API_REPLICAS and GOVOPLAN_EXPECTED_WORKER_REPLICAS to non-negative integers.",
)
return
if api_replicas < 1 or worker_replicas < 0:
collector.add(
"error",
"GOVOPLAN_EXPECTED_API_REPLICAS",
"Expected replica counts are outside their supported range.",
"Configure at least one API replica and zero or more worker replicas.",
)
if state_profile == "local":
if api_replicas > 1 or worker_replicas > 1:
collector.add(
"error",
"GOVOPLAN_STATE_PROFILE",
"A local-state profile cannot safely run replicated API or worker nodes.",
"Use `host-shared` with one shared host volume, or `shared` with PostgreSQL, Redis, and S3-compatible object storage.",
)
return
installation_id = _clean(env.get("GOVOPLAN_INSTALLATION_ID"))
if not installation_id or (
state_profile == "shared" and installation_id == "govoplan-local"
):
collector.add(
"error",
"GOVOPLAN_INSTALLATION_ID",
"Shared-state deployments require a stable installation identifier.",
"Set one immutable deployment-wide GOVOPLAN_INSTALLATION_ID on every node.",
)
if _database_backend(_clean(env.get("DATABASE_URL"))) != "postgresql":
collector.add(
"error",
"DATABASE_URL",
"Shared-state deployments require PostgreSQL.",
"Point every API, scheduler, and worker node at the same logical PostgreSQL service.",
)
if not _clean(env.get("REDIS_URL")):
collector.add(
"error",
"REDIS_URL",
"Shared-state deployments require a common Redis service.",
"Configure the same Redis endpoint for all API and worker nodes.",
)
if (
state_profile == "shared"
and (_clean(env.get("FILE_STORAGE_BACKEND")) or "local").lower() != "s3"
):
collector.add(
"error",
"FILE_STORAGE_BACKEND",
"Shared-state deployments cannot use node-local object storage.",
"Set FILE_STORAGE_BACKEND=s3 and configure one shared S3-compatible bucket.",
)
try:
heartbeat = int(_clean(env.get("GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS")) or "15")
stale_after = int(
_clean(env.get("GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS")) or "60"
)
except ValueError:
collector.add(
"error",
"GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS",
"Runtime heartbeat and stale intervals must be integers.",
"Use a heartbeat interval shorter than one third of the stale interval.",
)
else:
if heartbeat < 2 or stale_after < max(10, heartbeat * 3):
collector.add(
"error",
"GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS",
"Runtime stale detection leaves insufficient room for missed heartbeats.",
"Set stale-after to at least three heartbeat intervals and at least ten seconds.",
)
def _validate_outbound_connector_policy(
env: Mapping[str, str],
runtime: _RuntimeProfile,
collector: _ConfigIssueCollector,
) -> None:
private_networks = _clean(
env.get("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS")
).lower()
if runtime.production_like and private_networks not in {
"true",
"false",
"1",
"0",
"yes",
"no",
"on",
"off",
}:
collector.add(
"error",
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
"Private-network connector access must be an explicit deployment decision.",
"Set GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false for public-only egress, or true when this deployment must reach internal services.",
)
for key in (
"GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
"GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES",
"GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES",
):
value = _clean(env.get(key))
if not value:
continue
try:
parsed = int(value)
except ValueError:
parsed = 0
if parsed <= 0:
collector.add(
"error",
key,
f"{key} must be a positive byte count.",
"Use a positive integer byte limit.",
)
secret_env_names = [
item.strip()
for item in env.get("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST", "").split(",")
if item.strip()
]
if any(
re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", item) is None
for item in secret_env_names
):
collector.add(
"error",
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
"Connector secret environment allowlist contains an invalid variable name.",
"Use a comma-separated list of exact environment variable names, or leave the setting empty.",
)
ca_bundle_paths = [
item.strip()
for item in env.get("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST", "").split(",")
if item.strip()
]
if any(not Path(item).is_absolute() for item in ca_bundle_paths):
collector.add(
"error",
"GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST",
"Connector CA bundle allowlist contains a non-absolute path.",
"Use comma-separated absolute paths mounted on every API and connector worker, or leave the setting empty.",
)
def _validate_module_catalog_trust(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(
env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG")
)
if not runtime.production or not catalog_source:
return
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE")):
collector.add("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "A module catalog source is configured without a trusted keyring file.", "Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.")
collector.add(
"error",
"GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE",
"A module catalog source is configured without a trusted keyring file.",
"Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.",
)
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL")):
collector.add("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL", "A module catalog source is configured without an approved release channel.", "Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.")
collector.add(
"error",
"GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL",
"A module catalog source is configured without an approved release channel.",
"Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.",
)
def _self_hosted_env_template(master_key: str) -> str:
@@ -265,6 +713,13 @@ def _self_hosted_env_template(master_key: str) -> str:
APP_ENV=production
GOVOPLAN_INSTALL_PROFILE=self-hosted
GOVOPLAN_INSTALLATION_ID=govoplan-production
GOVOPLAN_STATE_PROFILE=local
GOVOPLAN_RUNTIME_ROLE=api
GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS=15
GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS=60
GOVOPLAN_EXPECTED_API_REPLICAS=1
GOVOPLAN_EXPECTED_WORKER_REPLICAS=1
MASTER_KEY_B64={master_key}
DATABASE_URL=postgresql+psycopg://govoplan:change-me@127.0.0.1:5432/govoplan
@@ -274,10 +729,34 @@ ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,aud
CELERY_ENABLED=true
REDIS_URL=redis://127.0.0.1:6379/0
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
CELERY_QUEUES=send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
# Deployment-wide connector egress policy. Enable private networks only when
# this installation intentionally integrates with internal services.
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES=536870912
# Exact names/paths only. Keep empty until a deployment-owned connector needs them.
GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=
GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=
GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES=536870912
GOVOPLAN_HTTP_HSTS_SECONDS=31536000
AUTH_LOGIN_THROTTLE_ENABLED=true
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS=30
CORS_ORIGINS=https://govoplan.example.org
GOVOPLAN_TRUSTED_HOSTS=govoplan.example.org
# Uvicorn reads FORWARDED_ALLOW_IPS when proxy headers are enabled. Keep this
# restricted to the actual reverse proxy address or network.
FORWARDED_ALLOW_IPS=127.0.0.1
AUTH_COOKIE_SECURE=true
AUTH_COOKIE_SAMESITE=lax
AUTH_COOKIE_DOMAIN=
@@ -285,6 +764,12 @@ AUTH_COOKIE_DOMAIN=
FILE_STORAGE_BACKEND=local
FILE_STORAGE_LOCAL_ROOT=/var/lib/govoplan/files
FILE_STORAGE_LOCAL_FALLBACK_ROOTS=
FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false
FILE_STORAGE_S3_ENDPOINT_TRUSTED=false
FILE_ARCHIVE_MAX_ENTRIES=10000
FILE_ARCHIVE_MAX_EXPANDED_BYTES=2147483648
FILE_ARCHIVE_MAX_EXPANSION_RATIO=100
FILE_ARCHIVE_PREVIEW_TTL_SECONDS=1800
DEV_AUTO_MIGRATE_ENABLED=false
DEV_BOOTSTRAP_ENABLED=false
@@ -302,6 +787,13 @@ def _production_like_env_template(master_key: str) -> str:
APP_ENV=staging
GOVOPLAN_INSTALL_PROFILE=production-like
GOVOPLAN_INSTALLATION_ID=govoplan-production-like
GOVOPLAN_STATE_PROFILE=local
GOVOPLAN_RUNTIME_ROLE=api
GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS=15
GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS=60
GOVOPLAN_EXPECTED_API_REPLICAS=1
GOVOPLAN_EXPECTED_WORKER_REPLICAS=1
MASTER_KEY_B64={master_key}
GOVOPLAN_PRODUCTION_LIKE_POSTGRES_DB=govoplan
@@ -318,14 +810,39 @@ DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
REDIS_URL=redis://127.0.0.1:56379/0
CELERY_ENABLED=true
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
CELERY_QUEUES=send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES=536870912
GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=
GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=
GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES=536870912
GOVOPLAN_HTTP_HSTS_SECONDS=0
AUTH_LOGIN_THROTTLE_ENABLED=true
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS=30
ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
GOVOPLAN_TRUSTED_HOSTS=127.0.0.1,localhost,testserver
FORWARDED_ALLOW_IPS=127.0.0.1
AUTH_COOKIE_SECURE=false
FILE_STORAGE_BACKEND=local
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false
FILE_STORAGE_S3_ENDPOINT_TRUSTED=false
FILE_ARCHIVE_MAX_ENTRIES=10000
FILE_ARCHIVE_MAX_EXPANDED_BYTES=2147483648
FILE_ARCHIVE_MAX_EXPANSION_RATIO=100
FILE_ARCHIVE_PREVIEW_TTL_SECONDS=1800
DEV_AUTO_MIGRATE_ENABLED=false
DEV_BOOTSTRAP_ENABLED=true
"""
@@ -336,7 +853,11 @@ def _clean(value: str | None) -> str:
def _csv(value: str | None) -> tuple[str, ...]:
return tuple(dict.fromkeys(item.strip() for item in str(value or "").split(",") if item.strip()))
return tuple(
dict.fromkeys(
item.strip() for item in str(value or "").split(",") if item.strip()
)
)
def _truthy(value: str | None) -> bool:
File diff suppressed because it is too large Load Diff
+34
View File
@@ -10,6 +10,9 @@ from govoplan_core.core.module_management import ModuleManagementError, REQUIRED
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import configure_runtime
from govoplan_core.core.workflows import (
workflow_definition_contribution_provider,
)
from govoplan_core.server.route_validation import validate_router_can_mount
@@ -67,6 +70,17 @@ class ModuleLifecycleManager:
def mounted_module_ids(self) -> tuple[str, ...]:
return tuple(sorted(self._mounted_modules))
def live_apply_enabled(self) -> bool:
configured = getattr(
self.settings,
"module_live_apply_enabled",
None,
)
if configured is not None:
return bool(configured)
app_env = str(getattr(self.settings, "app_env", "dev")).casefold()
return app_env in {"dev", "development", "local", "test", "testing"}
def apply_enabled_modules(
self,
requested_enabled: Sequence[str],
@@ -105,6 +119,8 @@ class ModuleLifecycleManager:
if hook is not None:
hook(self.context)
self.reconcile_workflow_definitions()
if self._app is not None:
self._app.openapi_schema = None
@@ -116,6 +132,24 @@ class ModuleLifecycleManager:
migrations_applied=migrate,
)
def reconcile_workflow_definitions(self) -> Mapping[str, object]:
if not any(
manifest.workflow_definitions for manifest in self.registry.manifests()
):
return {"skipped": True, "reason": "no_contributions"}
provider = workflow_definition_contribution_provider(self.registry)
if provider is None:
return {"skipped": True, "reason": "provider_unavailable"}
from govoplan_core.db.session import get_database
with get_database().session() as session:
result = provider.reconcile(session)
session.commit()
if self._app is not None:
self._app.state.govoplan_workflow_reconciliation = dict(result)
return dict(result)
def _mount_module_router(self, module_id: str) -> bool:
if module_id in self._mounted_modules:
return False
+151
View File
@@ -0,0 +1,151 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
CAPABILITY_MAIL_DELIVERY_OUTBOX = "mail.delivery_outbox"
CAPABILITY_MAIL_NOTIFICATION_DELIVERY = "mail.notificationDelivery"
CAPABILITY_MAIL_BOUNCE_PROCESSING = "mail.bounce_processing"
@dataclass(frozen=True, slots=True)
class NotificationMailDeliveryRequest:
tenant_id: str
notification_id: str
recipient: str
subject: str
body_text: str
body_html: str | None = None
action_url: str | None = None
mail_profile_id: str | None = None
from_address: str | None = None
smtp_server_id: str | None = None
smtp_credential_id: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class NotificationMailDeliveryProvider(Protocol):
"""Mail-owned durable submission boundary for notification email."""
def submit_notification_mail(
self,
session: object,
request: NotificationMailDeliveryRequest,
) -> Mapping[str, object]:
...
@runtime_checkable
class MailDeliveryOutboxProvider(Protocol):
"""Stable worker boundary for Mail-owned external delivery effects."""
def dispatch_due(
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 25,
worker_id: str | None = None,
) -> Mapping[str, object]:
...
def purge_expired(
self,
session: object,
*,
limit: int = 250,
) -> Mapping[str, object]:
...
@dataclass(frozen=True, slots=True)
class MailBounceObservationRef:
id: str
tenant_id: str
profile_id: str
folder: str
uid: str
original_message_id: str | None
command_id: str | None
recipient: str | None
action: str
status_code: str | None
diagnostic: str | None
permanent: bool
observed_at: datetime
matched: bool
evidence: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class MailBounceProcessingProvider(Protocol):
"""Mail-owned DSN ingestion and durable correlation boundary."""
def process_raw_message(
self,
session: object,
*,
tenant_id: str,
profile_id: str,
folder: str,
uid: str,
raw_message: bytes,
) -> tuple[MailBounceObservationRef, ...]:
...
def scan_due(
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 100,
) -> Mapping[str, object]:
...
def observations_for_commands(
self,
session: object,
*,
tenant_id: str,
command_ids: tuple[str, ...],
) -> Mapping[str, tuple[MailBounceObservationRef, ...]]:
...
def notification_mail_delivery_provider(
registry: object | None,
) -> NotificationMailDeliveryProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_MAIL_NOTIFICATION_DELIVERY)
):
return None
provider = registry.require_capability(CAPABILITY_MAIL_NOTIFICATION_DELIVERY)
if not isinstance(provider, NotificationMailDeliveryProvider):
raise TypeError(
"mail.notificationDelivery provider does not implement "
"NotificationMailDeliveryProvider"
)
return provider
def mail_bounce_processing_provider(
registry: object | None,
) -> MailBounceProcessingProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_MAIL_BOUNCE_PROCESSING)
):
return None
provider = registry.require_capability(CAPABILITY_MAIL_BOUNCE_PROCESSING)
if not isinstance(provider, MailBounceProcessingProvider):
raise TypeError(
"mail.bounce_processing provider does not implement "
"MailBounceProcessingProvider"
)
return provider
+8 -4
View File
@@ -79,15 +79,19 @@ def drop_table_retirement_provider(
warnings.append("Tables not present and therefore skipped: " + ", ".join(missing_names))
def executor(execute_session: object, _module_id: str) -> None:
if not hasattr(execute_session, "get_bind"):
if not hasattr(execute_session, "connection"):
raise RuntimeError("No database session is available for destructive table retirement.")
execute_bind = execute_session.get_bind() # type: ignore[attr-defined]
live_inspector = inspect(execute_bind)
# Enlist schema retirement in the caller's active transaction. An
# Engine returned by Session.get_bind() may acquire a second
# connection, separating the DROP from module-specific secret
# scrubbing/audit writes and deadlocking on their uncommitted locks.
execute_connection = execute_session.connection() # type: ignore[attr-defined]
live_inspector = inspect(execute_connection)
live_tables = [table for table in tables if live_inspector.has_table(table.name)]
if not live_tables:
return
metadata = live_tables[0].metadata
metadata.drop_all(bind=execute_bind, tables=live_tables, checkfirst=True)
metadata.drop_all(bind=execute_connection, tables=live_tables, checkfirst=True)
return MigrationRetirementPlan(
supported=True,
+64 -13
View File
@@ -15,7 +15,8 @@ import re
import shlex
import shutil
import sqlite3
import subprocess
import stat
import subprocess # nosec B404 - installer commands are structured and policy-validated before execution.
import sys
import tomllib
from typing import Any, Literal
@@ -334,6 +335,18 @@ def module_install_preflight(
issues.append(ModuleInstallerIssue("warning", "empty_plan", "No planned package changes are present."))
if not maintenance_mode:
issues.append(ModuleInstallerIssue("blocker", "maintenance_required", "Package changes require maintenance mode."))
if os.getenv("GOVOPLAN_STATE_PROFILE", "local").strip().lower() == "shared":
issues.append(
ModuleInstallerIssue(
"blocker",
"immutable_cluster_release_required",
(
"Shared-state deployments cannot mutate packages on one runtime node. "
"Build and roll out one immutable release image across every API, worker, "
"and scheduler node."
),
)
)
activation_candidates = desired_modules_after_package_plan(desired_sequence, plan)
issues.extend(module_manifest_compatibility_issues(available, module_ids=activation_candidates))
@@ -579,7 +592,7 @@ def _prepare_module_install_run(
) -> _ModuleInstallRunState:
run_id = _run_id()
run_dir = effective_runtime_dir / "runs" / run_id
run_dir.mkdir(parents=True, exist_ok=False)
_create_private_installer_run_dir(run_dir)
commands = structured_install_commands(
plan,
webui_root=webui_root,
@@ -2445,8 +2458,8 @@ def _migration_provider_modules(
target_metadata: Mapping[str, Mapping[str, object]],
) -> dict[str, tuple[tuple[str, str], ...]]:
providers: dict[str, list[tuple[str, str]]] = defaultdict(list)
for module_id, metadata in target_metadata.items():
for provided in metadata.get("provides_interfaces", ()):
for module_id, module_metadata in target_metadata.items():
for provided in module_metadata.get("provides_interfaces", ()):
if not isinstance(provided, Mapping):
continue
name = provided.get("name") if isinstance(provided.get("name"), str) else None
@@ -3327,12 +3340,6 @@ def _wait_for_health_urls(urls: Iterable[str], *, timeout_seconds: float, interv
return payload
def _run_restart_command_legacy(command: str | None) -> dict[str, object] | None:
if not command:
return None
return _run_restart_command(command)
def _planned_python_install_packages(record: Mapping[str, object]) -> tuple[str, ...]:
raw_plan = record.get("plan")
if not isinstance(raw_plan, list):
@@ -3684,10 +3691,21 @@ def _snapshot_sqlite_database(run_dir: Path, database_url: str | None) -> dict[s
source.backup(target)
else:
backup_path.touch()
with closing(sqlite3.connect(str(backup_path))) as candidate:
row = candidate.execute("PRAGMA integrity_check").fetchone()
integrity = str(row[0] if row else "missing result")
if integrity.lower() != "ok":
raise ModuleInstallerError(
f"SQLite backup failed its restore-readiness integrity check: {integrity}"
)
return {
"type": "sqlite",
"source": str(db_path),
"path": backup_path.name,
"restore_check": {
"type": "sqlite_integrity_check",
"result": integrity,
},
}
@@ -3828,14 +3846,47 @@ def _restore_external_database_snapshot(
def _write_installer_secret(path: Path, value: str | None) -> str | None:
if not value:
return None
path.write_text(value, encoding="utf-8")
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
flags |= getattr(os, "O_CLOEXEC", 0)
flags |= getattr(os, "O_NOFOLLOW", 0)
try:
path.chmod(0o600)
descriptor = os.open(path, flags, 0o600)
except OSError as exc:
logger.debug("Could not restrict installer secret file permissions for %s: %s", path, exc, exc_info=True)
raise ModuleInstallerError(f"Could not create private installer secret file: {path.name}") from exc
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
handle.write(value)
except OSError as exc:
path.unlink(missing_ok=True)
raise ModuleInstallerError(f"Could not write private installer secret file: {path.name}") from exc
try:
mode = stat.S_IMODE(path.stat().st_mode)
except OSError as exc:
path.unlink(missing_ok=True)
raise ModuleInstallerError(f"Could not verify private installer secret file: {path.name}") from exc
if mode != 0o600:
path.unlink(missing_ok=True)
raise ModuleInstallerError(f"Installer secret file permissions are not private: {path.name}")
return path.name
def _create_private_installer_run_dir(path: Path) -> None:
path.mkdir(parents=True, mode=0o700, exist_ok=False)
try:
# mkdir's requested mode is still reduced by the process umask. Set the
# exact owner-only mode so the directory remains usable with a strict
# deployment umask while never retaining group/other access.
path.chmod(0o700)
mode = stat.S_IMODE(path.stat().st_mode)
except OSError as exc:
path.rmdir()
raise ModuleInstallerError("Could not secure the installer run directory") from exc
if mode != 0o700:
path.rmdir()
raise ModuleInstallerError("Installer run directory permissions are not exactly owner-only")
def _database_url_from_external_snapshot(run_dir: Path, raw: Mapping[str, object]) -> str | None:
secret_name = raw.get("database_url_secret")
if isinstance(secret_name, str) and secret_name:
@@ -106,7 +106,8 @@ def installer_notification_body(event_kind: str, request: Mapping[str, object])
run_id = _result_run_id(request)
if run_id:
return ". Run: ".join((sentence, run_id))
return sentence + "."
# This helper returns plain notification text, not an HTTP/HTML response.
return sentence + "." # nosemgrep: python.flask.security.audit.directly-returned-format-string.directly-returned-format-string
def installer_notification_priority(status: str) -> int:
-1
View File
@@ -6,7 +6,6 @@ from datetime import UTC, datetime
import json
import os
from pathlib import Path
from typing import Any
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
+136 -32
View File
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from govoplan_core.admin.models import SystemSettings
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID, get_system_settings
from govoplan_core.core.access import DEFAULT_CAPABILITY_PROVIDERS
from govoplan_core.core.discovery import iter_module_entry_points
from govoplan_core.core.modules import ModuleManifest
from govoplan_core.db.session import get_database
@@ -17,8 +18,8 @@ from govoplan_core.server.registry import parse_enabled_modules
MODULE_SETTINGS_KEY = "module_management"
INSTALL_PLAN_KEY = "install_plan"
REQUIRED_PLATFORM_MODULES = ("access",)
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin")
REQUIRED_PLATFORM_MODULES: tuple[str, ...] = ()
PROTECTED_MODULES = ("admin",)
INSTALL_PLAN_ACTIONS = ("install", "update", "uninstall")
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
INSTALL_PLAN_SOURCES = ("manual", "catalog")
@@ -109,6 +110,7 @@ def startup_candidate_module_ids(
if desired is not None:
candidates.extend(str(item).strip() for item in desired if str(item).strip())
candidates.extend(REQUIRED_PLATFORM_MODULES)
candidates.extend(DEFAULT_CAPABILITY_PROVIDERS.values())
if "admin" in fallback:
candidates.append("admin")
return tuple(dict.fromkeys(candidates))
@@ -404,46 +406,148 @@ def plan_desired_enabled_modules(
*,
protected_modules: Iterable[str] = PROTECTED_MODULES,
) -> ModuleStatePlan:
requested = {str(item).strip() for item in requested_enabled if str(item).strip()}
protected = {str(item).strip() for item in protected_modules if str(item).strip()}
requested.update(protected)
requested = _normalized_module_ids(requested_enabled)
requested.update(_normalized_module_ids(protected_modules))
missing = sorted(module_id for module_id in requested if module_id not in available)
if missing:
raise ModuleManagementError("Unknown or uninstalled modules: " + ", ".join(missing))
added_dependencies: set[str] = set()
visiting: set[str] = set()
visited: set[str] = set()
ordered: list[str] = []
def visit(module_id: str) -> None:
if module_id in visited:
return
if module_id in visiting:
raise ModuleManagementError(f"Module dependency cycle includes {module_id!r}.")
manifest = available.get(module_id)
if manifest is None:
raise ModuleManagementError(f"Module {module_id!r} is required but not installed.")
visiting.add(module_id)
for dependency_id in manifest.dependencies:
if dependency_id not in available:
raise ModuleManagementError(f"Module {module_id!r} depends on uninstalled module {dependency_id!r}.")
if dependency_id not in requested:
added_dependencies.add(dependency_id)
requested.add(dependency_id)
visit(dependency_id)
visiting.remove(module_id)
visited.add(module_id)
ordered.append(module_id)
for module_id in sorted(requested):
visit(module_id)
added_dependencies = _expand_enabled_module_closure(requested, available)
ordered = _order_enabled_modules(requested, available, added_dependencies)
return ModuleStatePlan(
enabled_modules=tuple(dict.fromkeys(ordered)),
added_dependencies=tuple(sorted(added_dependencies)),
)
def _normalized_module_ids(values: Iterable[str]) -> set[str]:
return {str(item).strip() for item in values if str(item).strip()}
def _expand_enabled_module_closure(
requested: set[str],
available: Mapping[str, ModuleManifest],
) -> set[str]:
added: set[str] = set()
while True:
dependencies = {
dependency
for module_id in requested
for dependency in available[module_id].dependencies
}
_require_installed_modules(
dependencies,
available,
message="Required modules are not installed: ",
)
dependency_additions = dependencies - requested
added.update(dependency_additions)
requested.update(dependency_additions)
providers = _missing_capability_providers(requested, available)
_require_installed_modules(
providers,
available,
message="Required capability providers are not installed: ",
)
additions = providers - requested
if not additions:
return added
added.update(additions)
requested.update(additions)
def _missing_capability_providers(
requested: set[str],
available: Mapping[str, ModuleManifest],
) -> set[str]:
provided = {
capability
for module_id in requested
for capability in available[module_id].capability_factories
}
return {
provider_id
for module_id in requested
for capability in available[module_id].required_capabilities
if capability not in provided
for provider_id in (DEFAULT_CAPABILITY_PROVIDERS.get(capability),)
if provider_id is not None
}
def _require_installed_modules(
module_ids: set[str],
available: Mapping[str, ModuleManifest],
*,
message: str,
) -> None:
missing = sorted(module_id for module_id in module_ids if module_id not in available)
if missing:
raise ModuleManagementError(message + ", ".join(missing))
def _order_enabled_modules(
requested: set[str],
available: Mapping[str, ModuleManifest],
added_dependencies: set[str],
) -> list[str]:
visiting: set[str] = set()
visited: set[str] = set()
ordered: list[str] = []
for module_id in sorted(requested):
_visit_enabled_module(
module_id,
requested=requested,
available=available,
added_dependencies=added_dependencies,
visiting=visiting,
visited=visited,
ordered=ordered,
)
return ordered
def _visit_enabled_module(
module_id: str,
*,
requested: set[str],
available: Mapping[str, ModuleManifest],
added_dependencies: set[str],
visiting: set[str],
visited: set[str],
ordered: list[str],
) -> None:
if module_id in visited:
return
if module_id in visiting:
raise ModuleManagementError(f"Module dependency cycle includes {module_id!r}.")
manifest = available.get(module_id)
if manifest is None:
raise ModuleManagementError(f"Module {module_id!r} is required but not installed.")
visiting.add(module_id)
for dependency_id in manifest.dependencies:
if dependency_id not in available:
raise ModuleManagementError(
f"Module {module_id!r} depends on uninstalled module {dependency_id!r}."
)
if dependency_id not in requested:
added_dependencies.add(dependency_id)
requested.add(dependency_id)
_visit_enabled_module(
dependency_id,
requested=requested,
available=available,
added_dependencies=added_dependencies,
visiting=visiting,
visited=visited,
ordered=ordered,
)
visiting.remove(module_id)
visited.add(module_id)
ordered.append(module_id)
def module_dependents(available: Mapping[str, ModuleManifest]) -> dict[str, tuple[str, ...]]:
dependents: dict[str, list[str]] = {module_id: [] for module_id in available}
for manifest in available.values():
@@ -3,6 +3,7 @@ from __future__ import annotations
import base64
import binascii
from collections import defaultdict
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
@@ -16,6 +17,11 @@ from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
from govoplan_core.core.provider_governance import (
external_provider_from_mapping,
module_architecture_from_mapping,
module_architecture_issues,
)
from govoplan_core.security.http_fetch import fetch_http_text, is_http_url
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
@@ -613,6 +619,72 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
"notes": _optional_str(value, "notes"),
"tags": _string_list(value.get("tags")),
}
raw_architecture = value.get("architecture")
if raw_architecture is not None:
if not isinstance(raw_architecture, Mapping):
raise ValueError(
f"Module package catalog architecture for {module_id!r} must be an object."
)
architecture = module_architecture_from_mapping(raw_architecture)
issues = module_architecture_issues(
architecture,
has_migrations=bool(item["migration_tasks"])
or bool(item["migration_notes"]),
)
if issues:
raise ValueError(
f"Module package catalog architecture for {module_id!r} is invalid: "
+ "; ".join(issues)
)
item["architecture"] = architecture.to_dict()
raw_providers = value.get("external_providers")
if raw_providers is not None:
if not isinstance(raw_providers, list):
raise ValueError(
f"Module package catalog external_providers for {module_id!r} must be a list."
)
providers = []
seen_provider_ids: set[str] = set()
for raw_provider in raw_providers:
if not isinstance(raw_provider, Mapping):
raise ValueError(
f"Module package catalog external provider entries for {module_id!r} must be objects."
)
provider = external_provider_from_mapping(raw_provider)
if provider.module_id != module_id:
raise ValueError(
f"Module package catalog provider {provider.id!r} belongs to "
f"{provider.module_id!r}, not {module_id!r}."
)
if provider.id in seen_provider_ids:
raise ValueError(
f"Module package catalog has duplicate provider {provider.id!r}."
)
seen_provider_ids.add(provider.id)
providers.append(provider)
if providers and "architecture" not in item:
raise ValueError(
f"Module package catalog {module_id!r} declares external providers without architecture metadata."
)
if providers:
architecture_payload = item["architecture"]
if not isinstance(architecture_payload, Mapping):
raise ValueError(
f"Module package catalog {module_id!r} has invalid architecture metadata."
)
architecture_modes = set(
_string_list(architecture_payload.get("supported_authority_modes"))
)
provider_modes = {
mode for provider in providers for mode in provider.authority_modes
}
missing_modes = provider_modes - architecture_modes
if missing_modes:
raise ValueError(
f"Module package catalog {module_id!r} provider modes are missing from architecture metadata: "
+ ", ".join(sorted(missing_modes))
)
item["external_providers"] = [provider.to_dict() for provider in providers]
if not version_range_is_valid(
version_min=item["current_version_min"] if isinstance(item["current_version_min"], str) else None,
version_max_exclusive=item["current_version_max_exclusive"] if isinstance(item["current_version_max_exclusive"], str) else None,
+148
View File
@@ -4,8 +4,22 @@ from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any, Literal, Protocol, TYPE_CHECKING
from govoplan_core.core.ownership import OwnershipProviderRegistration
from govoplan_core.core.provider_governance import (
ExternalProviderDeclaration,
ExternalProviderStateProviderRegistration,
ModuleArchitectureDeclaration,
)
from govoplan_core.core.views import ViewSurface
if TYPE_CHECKING:
from fastapi import APIRouter
from govoplan_core.core.operations import OperationalCheckProviderRegistration
from govoplan_core.core.search import (
SearchProviderRegistration,
SearchSourceProviderRegistration,
)
from govoplan_core.core.workflows import WorkflowDefinitionContribution
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
@@ -45,6 +59,7 @@ class RoleTemplate:
level: PermissionLevel = "tenant"
managed: bool = True
protected: bool = False
default_authenticated: bool = False
@dataclass(frozen=True, slots=True)
@@ -56,6 +71,7 @@ class NavItem:
required_all: tuple[str, ...] = ()
required_any: tuple[str, ...] = ()
order: int = 100
surface_id: str | None = None
@@ -67,6 +83,16 @@ class FrontendRoute:
required_all: tuple[str, ...] = ()
required_any: tuple[str, ...] = ()
order: int = 100
surface_id: str | None = None
@dataclass(frozen=True, slots=True)
class PublicFrontendRoute:
"""Explicitly allowlisted route that can render without authentication."""
path: str
component: str
order: int = 100
@dataclass(frozen=True, slots=True)
@@ -79,8 +105,10 @@ class FrontendModule:
asset_manifest_integrity: str | None = None
asset_manifest_contract_version: str = "1"
routes: tuple[FrontendRoute, ...] = ()
public_routes: tuple[PublicFrontendRoute, ...] = ()
nav_items: tuple[NavItem, ...] = ()
settings_routes: tuple[FrontendRoute, ...] = ()
view_surfaces: tuple[ViewSurface, ...] = ()
@dataclass(frozen=True, slots=True)
@@ -195,6 +223,19 @@ class ModuleContext:
DocumentationLayer = Literal["always", "configured", "available", "evidence"]
DocumentationLinkKind = Literal["runtime", "api", "repository", "wiki", "public"]
DocumentationType = Literal["admin", "user"]
DocumentationConfigurationState = Literal["enabled", "disabled", "inherited", "unavailable"]
DocumentationSourceKind = Literal[
"manifest",
"route",
"capability",
"policy",
"release_catalog",
"configuration_package",
"wiki",
"repository",
]
DocumentationSourceState = Literal["configured", "disabled", "unavailable"]
CapabilityStability = Literal["experimental", "stable", "deprecated"]
@dataclass(frozen=True, slots=True)
@@ -233,9 +274,40 @@ class DocumentationTopic:
i18n_key: str | None = None
translations: Mapping[str, Mapping[str, str]] = field(default_factory=dict)
source_module_id: str | None = None
version_min: str | None = None
version_max_exclusive: str | None = None
metadata: Mapping[str, Any] = field(default_factory=dict)
def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str, ...]:
"""Return fail-closed authoring issues for a user-facing workflow topic.
Topic conditions are alternatives. Every alternative therefore needs an
explicit scope constraint; otherwise one unscoped alternative would make
the workflow visible regardless of the actor's task authority.
"""
raw_kind = topic.metadata.get("kind")
kind = raw_kind.strip().lower().replace("_", "-") if isinstance(raw_kind, str) else ""
if kind != "workflow" or "user" not in topic.documentation_types:
return ()
if not topic.conditions:
return ("user workflow topics must declare at least one scope-conditioned alternative",)
unscoped_alternatives = tuple(
index
for index, condition in enumerate(topic.conditions, start=1)
if not any(scope.strip() for scope in (*condition.required_scopes, *condition.any_scopes))
)
if not unscoped_alternatives:
return ()
alternatives = ", ".join(str(index) for index in unscoped_alternatives)
return (
"every user workflow condition alternative must declare required_scopes or any_scopes; "
f"unscoped alternative(s): {alternatives}",
)
@dataclass(frozen=True, slots=True)
class DocumentationContext:
registry: object
@@ -247,6 +319,53 @@ class DocumentationContext:
data: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DocumentationConfigurationDecision:
key: str
state: DocumentationConfigurationState
source: str | None = None
reason: str | None = None
DocumentationConfigurationResolver = Callable[
[DocumentationContext, tuple[str, ...]],
Mapping[str, DocumentationConfigurationDecision],
]
@dataclass(frozen=True, slots=True)
class DocumentationConfigurationProviderRegistration:
keys: tuple[str, ...]
resolve: DocumentationConfigurationResolver
@dataclass(frozen=True, slots=True)
class DocumentationSourceDefinition:
id: str
kind: DocumentationSourceKind
label: str
documentation_types: tuple[DocumentationType, ...] = ("admin",)
condition: DocumentationCondition = field(default_factory=DocumentationCondition)
state: DocumentationSourceState = "configured"
state_reason: str | None = None
provenance: Mapping[str, Any] = field(default_factory=dict)
inspection: Mapping[str, Any] = field(default_factory=dict)
link: DocumentationLink | None = None
configuration_key: str | None = None
@dataclass(frozen=True, slots=True)
class CapabilityDocumentation:
"""Provider-owned capability metadata safe for generic platform consumers."""
label: str
summary: str
contract_version: str | None = None
stability: CapabilityStability = "stable"
documentation_types: tuple[DocumentationType, ...] = ("admin",)
audience: tuple[str, ...] = ()
class ResourceAclProvider(Protocol):
resource_type: str
@@ -261,6 +380,10 @@ class ResourceAclProvider(Protocol):
TenantSummaryProvider = Callable[[object, str], Mapping[str, int]]
TenantSummaryBatchProvider = Callable[
[object, Sequence[str]],
Mapping[str, Mapping[str, int]],
]
@dataclass(frozen=True, slots=True)
@@ -307,12 +430,37 @@ class ModuleManifest:
nav_items: tuple[NavItem, ...] = ()
frontend: FrontendModule | None = None
resource_acl_providers: tuple[ResourceAclProvider, ...] = ()
ownership_providers: tuple[OwnershipProviderRegistration, ...] = ()
tenant_summary_providers: tuple[TenantSummaryProvider, ...] = ()
tenant_summary_batch_providers: tuple[TenantSummaryBatchProvider, ...] = ()
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = ()
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
capability_documentation: Mapping[str, CapabilityDocumentation] = field(default_factory=dict)
search_providers: tuple["SearchProviderRegistration", ...] = ()
search_sources: tuple["SearchSourceProviderRegistration", ...] = ()
operational_check_providers: tuple[
"OperationalCheckProviderRegistration",
...,
] = ()
architecture: ModuleArchitectureDeclaration | None = None
external_providers: tuple[ExternalProviderDeclaration, ...] = ()
external_provider_state_providers: tuple[
ExternalProviderStateProviderRegistration,
...,
] = ()
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
on_activate: LifecycleHook | None = None
on_deactivate: LifecycleHook | None = None
documentation: tuple[DocumentationTopic, ...] = ()
documentation_providers: tuple[DocumentationProvider, ...] = ()
documentation_configuration_providers: tuple[
DocumentationConfigurationProviderRegistration,
...,
] = ()
documentation_sources: tuple[DocumentationSourceDefinition, ...] = ()
# A renamed or extracted module may continue to own an established
# permission namespace. This keeps persisted grants stable while the
# runtime module ID changes.
permission_namespace: str | None = None
workflow_definitions: tuple["WorkflowDefinitionContribution", ...] = ()
+597
View File
@@ -0,0 +1,597 @@
from __future__ import annotations
from dataclasses import dataclass, field
from heapq import nsmallest
import os
from pathlib import Path
import tempfile
from typing import Any, Iterable, Protocol
from urllib.parse import urlsplit
from govoplan_core.security.outbound_http import (
OutboundHttpError,
response_limit,
validate_unpinned_sdk_http_url,
)
class StorageBackendError(RuntimeError):
"""Base error for the deployment-owned object-storage boundary."""
class StorageObjectMissing(StorageBackendError):
"""Raised when a referenced object no longer exists."""
@dataclass(frozen=True, slots=True)
class StorageObjectInfo:
key: str
size_bytes: int
@dataclass(frozen=True, slots=True)
class StorageObjectPage:
objects: tuple[StorageObjectInfo, ...]
next_cursor: str | None = None
class StorageBackend(Protocol):
"""Shared byte-object storage used by modules without cross-module imports."""
name: str
def put_bytes(
self,
key: str,
data: bytes,
*,
content_type: str | None = None,
) -> None: ...
def get_bytes(self, key: str) -> bytes: ...
def iter_bytes(
self,
key: str,
*,
chunk_size: int = 1024 * 1024,
) -> Iterable[bytes]: ...
def delete(self, key: str) -> None: ...
def exists(self, key: str) -> bool: ...
def stat(self, key: str) -> StorageObjectInfo: ...
def list_objects(
self,
*,
prefix: str,
after: str | None = None,
limit: int = 500,
) -> StorageObjectPage: ...
@dataclass(slots=True)
class LocalFilesystemStorageBackend:
root: Path
fallback_roots: tuple[Path, ...] = field(default_factory=tuple)
name: str = "local"
def __post_init__(self) -> None:
self.root = self.root.expanduser().resolve()
self.fallback_roots = tuple(
root.expanduser().resolve() for root in self.fallback_roots if root
)
self.root.mkdir(parents=True, exist_ok=True)
def _path_for_root(self, root: Path, key: str) -> Path:
normalized = normalize_storage_key(key)
path = (root / normalized).resolve()
if not path.is_relative_to(root):
raise StorageBackendError("Storage key escapes local storage root")
return path
def _path(self, key: str) -> Path:
return self._path_for_root(self.root, key)
def _readable_path(self, key: str) -> Path:
primary = self._path(key)
if primary.exists() and primary.is_file():
return primary
for root in self.fallback_roots:
candidate = self._path_for_root(root, key)
if candidate.exists() and candidate.is_file():
return candidate
raise StorageObjectMissing("Stored object does not exist")
def put_bytes(
self,
key: str,
data: bytes,
*,
content_type: str | None = None,
) -> None:
del content_type
path = self._path(key)
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(
prefix=f".{path.name}.",
suffix=".tmp",
dir=path.parent,
)
temporary = Path(temporary_name)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "wb") as stream:
descriptor = -1
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
temporary.replace(path)
finally:
if descriptor >= 0:
os.close(descriptor)
temporary.unlink(missing_ok=True)
def get_bytes(self, key: str) -> bytes:
return self._readable_path(key).read_bytes()
def iter_bytes(
self,
key: str,
*,
chunk_size: int = 1024 * 1024,
) -> Iterable[bytes]:
path = self._readable_path(key)
with path.open("rb") as handle:
while True:
chunk = handle.read(chunk_size)
if not chunk:
break
yield chunk
def delete(self, key: str) -> None:
path = self._path(key)
if path.exists() and path.is_file():
path.unlink()
def exists(self, key: str) -> bool:
try:
self._readable_path(key)
except StorageObjectMissing:
return False
return True
def stat(self, key: str) -> StorageObjectInfo:
path = self._readable_path(key)
return StorageObjectInfo(
key=normalize_storage_key(key),
size_bytes=path.stat().st_size,
)
def list_objects(
self,
*,
prefix: str,
after: str | None = None,
limit: int = 500,
) -> StorageObjectPage:
normalized_prefix = normalize_storage_prefix(prefix)
normalized_after = normalize_storage_key(after) if after else None
normalized_limit = max(1, min(int(limit), 5000))
def matching_objects() -> Iterable[StorageObjectInfo]:
for path in _iter_local_files(self.root):
key = path.relative_to(self.root).as_posix()
if not key.startswith(normalized_prefix) or (
normalized_after is not None and key <= normalized_after
):
continue
yield StorageObjectInfo(
key=key,
size_bytes=path.stat().st_size,
)
candidates = nsmallest(
normalized_limit + 1,
matching_objects(),
key=lambda item: item.key,
)
has_more = len(candidates) > normalized_limit
page = tuple(candidates[:normalized_limit])
return StorageObjectPage(
objects=page,
next_cursor=page[-1].key if has_more and page else None,
)
@dataclass(slots=True)
class S3StorageBackend:
bucket: str
endpoint_url: str
region_name: str
access_key_id: str
secret_access_key: str
deployment_managed: bool = False
endpoint_trusted: bool = False
name: str = "s3"
_client: Any = field(default=None, init=False, repr=False)
@property
def client(self):
if self._client is not None:
return self._client
if self.deployment_managed:
endpoint_url = _deployment_managed_garage_endpoint(self.endpoint_url)
elif self.endpoint_trusted:
endpoint_url = _trusted_deployment_endpoint(self.endpoint_url)
else:
try:
endpoint_url = validate_unpinned_sdk_http_url(
self.endpoint_url,
label="Object storage S3 endpoint",
)
except OutboundHttpError as exc:
raise StorageBackendError(str(exc)) from exc
try:
import boto3
from botocore.config import Config
except ModuleNotFoundError as exc:
raise StorageBackendError(
"boto3 is required for the S3 storage backend"
) from exc
options: dict[str, object] = {
"endpoint_url": endpoint_url,
"region_name": self.region_name,
"aws_access_key_id": self.access_key_id,
"aws_secret_access_key": self.secret_access_key,
}
if self.deployment_managed:
options["config"] = Config(s3={"addressing_style": "path"})
self._client = boto3.client("s3", **options)
return self._client
def put_bytes(
self,
key: str,
data: bytes,
*,
content_type: str | None = None,
) -> None:
normalized = normalize_storage_key(key)
max_bytes = response_limit("file")
if len(data) > max_bytes:
raise StorageBackendError(
f"Stored object exceeds the deployment limit of {max_bytes} bytes"
)
kwargs: dict[str, object] = {
"Bucket": self.bucket,
"Key": normalized,
"Body": data,
}
if content_type:
kwargs["ContentType"] = content_type
try:
self.client.put_object(**kwargs)
except Exception as exc: # pragma: no cover - depends on S3 backend
raise StorageBackendError(str(exc)) from exc
def get_bytes(self, key: str) -> bytes:
normalized = normalize_storage_key(key)
try:
obj = self.client.get_object(
Bucket=self.bucket,
Key=normalized,
)
max_bytes = response_limit("file")
body = obj["Body"]
try:
_reject_declared_object_size(obj, max_bytes=max_bytes)
data = body.read(max_bytes + 1)
if len(data) > max_bytes:
raise StorageBackendError(
"Stored object exceeds the deployment limit of "
f"{max_bytes} bytes"
)
return data
finally:
if hasattr(body, "close"):
body.close()
except StorageBackendError:
raise
except Exception as exc: # pragma: no cover - depends on S3 backend
if _s3_missing_error(exc):
raise StorageObjectMissing("Stored object does not exist") from exc
raise StorageBackendError(str(exc)) from exc
def iter_bytes(
self,
key: str,
*,
chunk_size: int = 1024 * 1024,
) -> Iterable[bytes]:
normalized = normalize_storage_key(key)
try:
obj = self.client.get_object(
Bucket=self.bucket,
Key=normalized,
)
max_bytes = response_limit("file")
body = obj["Body"]
try:
_reject_declared_object_size(obj, max_bytes=max_bytes)
total = 0
while True:
chunk = body.read(chunk_size)
if not chunk:
break
total += len(chunk)
if total > max_bytes:
raise StorageBackendError(
"Stored object exceeds the deployment limit of "
f"{max_bytes} bytes"
)
yield chunk
finally:
if hasattr(body, "close"):
body.close()
except StorageBackendError:
raise
except Exception as exc: # pragma: no cover - depends on S3 backend
if _s3_missing_error(exc):
raise StorageObjectMissing("Stored object does not exist") from exc
raise StorageBackendError(str(exc)) from exc
def delete(self, key: str) -> None:
try:
self.client.delete_object(
Bucket=self.bucket,
Key=normalize_storage_key(key),
)
except Exception as exc: # pragma: no cover - depends on S3 backend
raise StorageBackendError(str(exc)) from exc
def exists(self, key: str) -> bool:
try:
self.client.head_object(
Bucket=self.bucket,
Key=normalize_storage_key(key),
)
return True
except Exception as exc:
if _s3_missing_error(exc):
return False
raise StorageBackendError(str(exc)) from exc
def stat(self, key: str) -> StorageObjectInfo:
normalized = normalize_storage_key(key)
try:
response = self.client.head_object(
Bucket=self.bucket,
Key=normalized,
)
except Exception as exc: # pragma: no cover - depends on S3 backend
if _s3_missing_error(exc):
raise StorageObjectMissing("Stored object does not exist") from exc
raise StorageBackendError(str(exc)) from exc
try:
size = int(response.get("ContentLength"))
except (AttributeError, TypeError, ValueError) as exc:
raise StorageBackendError(
"S3 object metadata did not include a valid size"
) from exc
return StorageObjectInfo(key=normalized, size_bytes=size)
def list_objects(
self,
*,
prefix: str,
after: str | None = None,
limit: int = 500,
) -> StorageObjectPage:
normalized_prefix = normalize_storage_prefix(prefix)
normalized_limit = max(1, min(int(limit), 1000))
kwargs: dict[str, object] = {
"Bucket": self.bucket,
"Prefix": normalized_prefix,
"MaxKeys": normalized_limit,
}
if after:
kwargs["StartAfter"] = normalize_storage_key(after)
try:
response = self.client.list_objects_v2(**kwargs)
except Exception as exc: # pragma: no cover - depends on S3 backend
raise StorageBackendError(str(exc)) from exc
objects = tuple(
StorageObjectInfo(
key=str(item["Key"]),
size_bytes=int(item.get("Size") or 0),
)
for item in response.get("Contents", ())
if isinstance(item, dict) and item.get("Key")
)
has_more = bool(response.get("IsTruncated"))
return StorageObjectPage(
objects=objects,
next_cursor=objects[-1].key if has_more and objects else None,
)
def configured_storage_backend(settings: object) -> StorageBackend:
"""Build the deployment-wide object store from Core settings.
Modules own their metadata and key namespaces. The deployment owns the
storage endpoint and credentials, so modules do not need to depend on the
Files package merely to persist opaque generated bytes.
"""
configured = (
str(getattr(settings, "file_storage_backend", "local") or "local")
.strip()
.lower()
)
if configured in {"local", "filesystem", "fs"}:
raw_fallbacks = str(
getattr(settings, "file_storage_local_fallback_roots", "") or ""
)
return LocalFilesystemStorageBackend(
Path(
str(
getattr(
settings,
"file_storage_local_root",
"runtime/files",
)
)
),
fallback_roots=tuple(
Path(item.strip()) for item in raw_fallbacks.split(",") if item.strip()
),
)
if configured in {"s3", "garage"}:
return S3StorageBackend(
bucket=str(
getattr(settings, "file_storage_s3_bucket", None)
or getattr(settings, "s3_bucket", "files")
),
endpoint_url=str(
getattr(settings, "file_storage_s3_endpoint_url", None)
or getattr(settings, "s3_endpoint_url", "")
),
region_name=str(
getattr(settings, "file_storage_s3_region", None)
or getattr(settings, "s3_region", "")
),
access_key_id=str(
getattr(settings, "file_storage_s3_access_key_id", None)
or getattr(settings, "s3_access_key_id", "")
),
secret_access_key=str(
getattr(
settings,
"file_storage_s3_secret_access_key",
None,
)
or getattr(settings, "s3_secret_access_key", "")
),
deployment_managed=bool(
getattr(
settings,
"file_storage_s3_deployment_managed",
False,
)
),
endpoint_trusted=bool(
getattr(
settings,
"file_storage_s3_endpoint_trusted",
False,
)
),
)
raise StorageBackendError(f"Unsupported object storage backend: {configured}")
def normalize_storage_key(value: str) -> str:
candidate = str(value or "").strip().replace("\\", "/")
parts = candidate.split("/")
if (
not candidate
or candidate.startswith("/")
or any(part in {"", ".", ".."} for part in parts)
or any(ord(character) < 32 for character in candidate)
):
raise StorageBackendError("Storage key is not a safe relative key")
return "/".join(parts)
def normalize_storage_prefix(value: str) -> str:
candidate = str(value or "").strip().replace("\\", "/")
if not candidate:
return ""
trailing_slash = candidate.endswith("/")
normalized = normalize_storage_key(candidate.rstrip("/"))
return normalized + ("/" if trailing_slash else "")
def _reject_declared_object_size(obj: object, *, max_bytes: int) -> None:
if not isinstance(obj, dict):
return
try:
declared_size = int(obj.get("ContentLength"))
except (TypeError, ValueError):
return
if declared_size > max_bytes:
raise StorageBackendError(
f"Stored object exceeds the deployment limit of {max_bytes} bytes"
)
def _iter_local_files(root: Path):
for entry in sorted(root.iterdir(), key=lambda item: item.name):
if entry.is_symlink():
continue
if entry.is_dir():
yield from _iter_local_files(entry)
elif entry.is_file():
yield entry
def _s3_missing_error(exc: Exception) -> bool:
response = getattr(exc, "response", None)
if not isinstance(response, dict):
return False
error = response.get("Error")
metadata = response.get("ResponseMetadata")
code = str(error.get("Code") if isinstance(error, dict) else "")
status_code = metadata.get("HTTPStatusCode") if isinstance(metadata, dict) else None
return code in {"404", "NoSuchKey", "NotFound"} or status_code == 404
def _deployment_managed_garage_endpoint(value: str) -> str:
endpoint = str(value or "").strip()
if endpoint != "http://garage:3900":
raise StorageBackendError(
"Deployment-managed S3 trust is restricted to http://garage:3900"
)
return endpoint
def _trusted_deployment_endpoint(value: str) -> str:
endpoint = str(value or "").strip()
parsed = urlsplit(endpoint)
try:
parsed.port
except ValueError as exc:
raise StorageBackendError(
"Deployment-trusted S3 endpoint has an invalid port"
) from exc
if (
parsed.scheme.lower() != "https"
or not parsed.hostname
or parsed.username
or parsed.password
or parsed.query
or parsed.fragment
or parsed.path not in {"", "/"}
):
raise StorageBackendError(
"Deployment-trusted S3 endpoint must be an HTTPS origin without "
"credentials, query, fragment, or path"
)
return endpoint.rstrip("/")
__all__ = [
"LocalFilesystemStorageBackend",
"S3StorageBackend",
"StorageBackend",
"StorageBackendError",
"StorageObjectInfo",
"StorageObjectMissing",
"StorageObjectPage",
"configured_storage_backend",
"normalize_storage_key",
"normalize_storage_prefix",
]
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from typing import Literal
OperationalCheckState = Literal["ok", "warning", "error", "inactive"]
@dataclass(frozen=True, slots=True)
class OperationalCheck:
"""A bounded module-owned runtime check exposed through the Ops module."""
id: str
label: str
state: OperationalCheckState
detail: str
readiness_critical: bool = False
metrics: Mapping[str, object] = field(default_factory=dict)
def as_dict(self) -> dict[str, object]:
return {
"id": self.id,
"label": self.label,
"state": self.state,
"detail": self.detail,
"readiness_critical": self.readiness_critical,
"metrics": dict(self.metrics),
}
OperationalCheckProvider = Callable[[], OperationalCheck]
@dataclass(frozen=True, slots=True)
class OperationalCheckProviderRegistration:
"""Register one independently executable operational check."""
module_id: str
check_id: str
provider: OperationalCheckProvider
cache_seconds: int = 60
+336 -2
View File
@@ -1,14 +1,58 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
ORGANIZATIONS_MODULE_ID = "organizations"
CAPABILITY_ORGANIZATION_DIRECTORY = "organizations.directory"
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY = (
"organizations.hierarchyDirectory"
)
OrganizationStatus = Literal["active", "inactive", "suspended"]
OrganizationResolutionStatus = Literal[
"active",
"inactive",
"missing",
"unreachable",
"invalid",
]
OrganizationHierarchyDirection = Literal["ancestors", "descendants"]
OrganizationLifecycleResource = Literal[
"unit_type",
"structure",
"relation_type",
"unit",
"relation",
"function_type",
"function",
]
OrganizationLifecycleAction = Literal[
"created",
"updated",
"moved",
"deactivated",
]
ORGANIZATION_LIFECYCLE_EVENT_SCHEMA_VERSION = 1
ORGANIZATION_LIFECYCLE_RESOURCES: tuple[
OrganizationLifecycleResource,
...,
] = (
"unit_type",
"structure",
"relation_type",
"unit",
"relation",
"function_type",
"function",
)
ORGANIZATION_LIFECYCLE_ACTIONS: tuple[
OrganizationLifecycleAction,
...,
] = ("created", "updated", "moved", "deactivated")
@dataclass(frozen=True, slots=True)
@@ -35,6 +79,143 @@ class OrganizationFunctionRef:
delegable: bool = False
act_in_place_allowed: bool = False
status: OrganizationStatus = "active"
settings: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class OrganizationUnitTypeRef:
id: str
tenant_id: str
slug: str
name: str
description: str | None = None
status: OrganizationStatus = "active"
@dataclass(frozen=True, slots=True)
class OrganizationFunctionTypeRef:
id: str
tenant_id: str
slug: str
name: str
organization_unit_type_id: str | None = None
description: str | None = None
delegable: bool = False
act_in_place_allowed: bool = False
status: OrganizationStatus = "active"
settings: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class OrganizationStructureRef:
id: str
tenant_id: str
slug: str
name: str
structure_kind: str
description: str | None = None
status: OrganizationStatus = "active"
@dataclass(frozen=True, slots=True)
class OrganizationRelationTypeRef:
id: str
tenant_id: str
slug: str
name: str
structure_id: str | None = None
source_unit_type_id: str | None = None
target_unit_type_id: str | None = None
is_hierarchical: bool = True
allow_cycles: bool = False
description: str | None = None
status: OrganizationStatus = "active"
@dataclass(frozen=True, slots=True)
class OrganizationHierarchyCatalogRef:
tenant_id: str
structures: tuple[OrganizationStructureRef, ...] = ()
relation_types: tuple[OrganizationRelationTypeRef, ...] = ()
@dataclass(frozen=True, slots=True)
class OrganizationHierarchyEdgeRef:
id: str
tenant_id: str
structure: OrganizationStructureRef
relation_type: OrganizationRelationTypeRef
source_unit_id: str
target_unit_id: str
valid_from: datetime | None = None
valid_until: datetime | None = None
status: OrganizationStatus = "active"
@dataclass(frozen=True, slots=True)
class OrganizationHierarchyMatchRef:
unit: OrganizationUnitRef
depth: int
path: tuple[OrganizationHierarchyEdgeRef, ...]
@dataclass(frozen=True, slots=True)
class OrganizationHierarchyResolution:
tenant_id: str
root_unit_id: str
direction: OrganizationHierarchyDirection
structure_id: str
relation_type_ids: tuple[str, ...]
max_depth: int
status: OrganizationResolutionStatus
root: OrganizationUnitRef | None = None
matches: tuple[OrganizationHierarchyMatchRef, ...] = ()
cycle_detected: bool = False
depth_limited: bool = False
diagnostics: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class OrganizationHierarchyPathResolution:
tenant_id: str
source_unit_id: str
target_unit_id: str
direction: OrganizationHierarchyDirection
structure_id: str
relation_type_ids: tuple[str, ...]
max_depth: int
status: OrganizationResolutionStatus
source: OrganizationUnitRef | None = None
target: OrganizationUnitRef | None = None
path: tuple[OrganizationHierarchyEdgeRef, ...] = ()
cycle_detected: bool = False
depth_limited: bool = False
diagnostics: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class OrganizationFunctionTypeResolution:
tenant_id: str
function_type_id: str
requested_unit_ids: tuple[str, ...]
status: OrganizationResolutionStatus
function_type: OrganizationFunctionTypeRef | None = None
matches: tuple[OrganizationFunctionRef, ...] = ()
missing_unit_ids: tuple[str, ...] = ()
inactive_unit_ids: tuple[str, ...] = ()
diagnostics: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class OrganizationUnitTypeResolution:
tenant_id: str
unit_type_id: str
status: OrganizationResolutionStatus
unit_type: OrganizationUnitTypeRef | None = None
structure_id: str | None = None
root_unit_id: str | None = None
matches: tuple[OrganizationUnitRef, ...] = ()
diagnostics: tuple[str, ...] = ()
@runtime_checkable
@@ -55,3 +236,156 @@ class OrganizationDirectory(Protocol):
include_subunits: bool = False,
) -> Sequence[OrganizationFunctionRef]:
...
@runtime_checkable
class OrganizationHierarchyDirectory(Protocol):
def hierarchy_catalog(
self,
tenant_id: str,
) -> OrganizationHierarchyCatalogRef:
...
def get_unit_type(
self,
tenant_id: str,
unit_type_id: str,
) -> OrganizationUnitTypeRef | None:
...
def get_function_type(
self,
tenant_id: str,
function_type_id: str,
) -> OrganizationFunctionTypeRef | None:
...
def resolve_functions_by_type(
self,
tenant_id: str,
function_type_id: str,
*,
organization_unit_ids: Sequence[str] = (),
) -> OrganizationFunctionTypeResolution:
...
def resolve_units_by_type(
self,
tenant_id: str,
unit_type_id: str,
*,
structure_id: str | None = None,
root_unit_id: str | None = None,
relation_type_ids: Sequence[str] = (),
direction: OrganizationHierarchyDirection = "descendants",
max_depth: int = 10,
) -> OrganizationUnitTypeResolution:
...
def resolve_hierarchy_relatives(
self,
tenant_id: str,
organization_unit_ids: Sequence[str],
*,
structure_id: str,
relation_type_ids: Sequence[str] = (),
direction: OrganizationHierarchyDirection = "ancestors",
max_depth: int = 10,
) -> Sequence[OrganizationHierarchyResolution]:
...
def resolve_hierarchy_paths(
self,
tenant_id: str,
unit_pairs: Sequence[tuple[str, str]],
*,
structure_id: str,
relation_type_ids: Sequence[str] = (),
direction: OrganizationHierarchyDirection = "descendants",
max_depth: int = 10,
) -> Sequence[OrganizationHierarchyPathResolution]:
...
def organization_directory(
registry: object | None,
) -> OrganizationDirectory | None:
capability = _capability(registry, CAPABILITY_ORGANIZATION_DIRECTORY)
return capability if isinstance(capability, OrganizationDirectory) else None
def organization_hierarchy_directory(
registry: object | None,
) -> OrganizationHierarchyDirectory | None:
capability = _capability(
registry,
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY,
)
return (
capability
if isinstance(capability, OrganizationHierarchyDirectory)
else None
)
def organization_lifecycle_event_type(
resource: OrganizationLifecycleResource,
action: OrganizationLifecycleAction,
) -> str:
if resource not in ORGANIZATION_LIFECYCLE_RESOURCES:
raise ValueError("Unsupported organization lifecycle resource.")
if action not in ORGANIZATION_LIFECYCLE_ACTIONS:
raise ValueError("Unsupported organization lifecycle action.")
return f"organizations.{resource}.{action}.v1"
ORGANIZATION_DIRECTORY_INVALIDATION_EVENT_TYPES = frozenset(
organization_lifecycle_event_type(resource, action)
for resource in ORGANIZATION_LIFECYCLE_RESOURCES
for action in ORGANIZATION_LIFECYCLE_ACTIONS
)
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
__all__ = [
"CAPABILITY_ORGANIZATION_DIRECTORY",
"CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY",
"ORGANIZATION_DIRECTORY_INVALIDATION_EVENT_TYPES",
"ORGANIZATION_LIFECYCLE_ACTIONS",
"ORGANIZATION_LIFECYCLE_EVENT_SCHEMA_VERSION",
"ORGANIZATION_LIFECYCLE_RESOURCES",
"ORGANIZATIONS_MODULE_ID",
"OrganizationDirectory",
"OrganizationFunctionRef",
"OrganizationFunctionTypeRef",
"OrganizationFunctionTypeResolution",
"OrganizationHierarchyDirection",
"OrganizationHierarchyCatalogRef",
"OrganizationHierarchyDirectory",
"OrganizationHierarchyEdgeRef",
"OrganizationHierarchyMatchRef",
"OrganizationHierarchyPathResolution",
"OrganizationHierarchyResolution",
"OrganizationLifecycleAction",
"OrganizationLifecycleResource",
"OrganizationRelationTypeRef",
"OrganizationResolutionStatus",
"OrganizationStatus",
"OrganizationStructureRef",
"OrganizationUnitRef",
"OrganizationUnitTypeRef",
"OrganizationUnitTypeResolution",
"organization_directory",
"organization_hierarchy_directory",
"organization_lifecycle_event_type",
]
+965
View File
@@ -0,0 +1,965 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import StrEnum
import hashlib
import json
from typing import Any, Protocol, runtime_checkable
import uuid
from sqlalchemy import DateTime, Index, Integer, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, Session, mapped_column
from govoplan_core.db.base import Base, TimestampMixin, utcnow
class OwnershipTransferKind(StrEnum):
OWNER_INITIATED = "owner_initiated"
TARGET_REQUESTED = "target_requested"
ADMINISTRATIVE_RECOVERY = "administrative_recovery"
class OwnershipTransferStatus(StrEnum):
AWAITING_OWNER_APPROVAL = "awaiting_owner_approval"
AWAITING_TARGET_ACCEPTANCE = "awaiting_target_acceptance"
AWAITING_RECOVERY_APPROVALS = "awaiting_recovery_approvals"
RECOVERY_SCHEDULED = "recovery_scheduled"
COMPLETED = "completed"
DECLINED = "declined"
CANCELLED = "cancelled"
EXPIRED = "expired"
TERMINAL_OWNERSHIP_TRANSFER_STATUSES = frozenset(
{
OwnershipTransferStatus.COMPLETED.value,
OwnershipTransferStatus.DECLINED.value,
OwnershipTransferStatus.CANCELLED.value,
OwnershipTransferStatus.EXPIRED.value,
}
)
class OwnershipTransferError(ValueError):
pass
class OwnershipAuthorizationError(PermissionError):
pass
class OwnershipIdempotencyConflict(OwnershipTransferError):
pass
class OwnershipTransferExpired(OwnershipTransferError):
pass
@dataclass(frozen=True, slots=True)
class OwnershipSubjectRef:
type: str
id: str
label: str | None = None
scopes: frozenset[str] = frozenset()
group_ids: frozenset[str] = frozenset()
recently_authenticated: bool = False
def __post_init__(self) -> None:
if not self.type.strip() or not self.id.strip():
raise ValueError("Ownership subjects require a type and id")
@dataclass(frozen=True, slots=True)
class OwnershipResourceRef:
module_id: str
resource_type: str
resource_id: str
def __post_init__(self) -> None:
if not self.module_id.strip() or not self.resource_type.strip() or not self.resource_id.strip():
raise ValueError("Ownership resources require module, type, and id")
@dataclass(frozen=True, slots=True)
class OwnershipActionDecision:
allowed: bool
reason: str | None = None
requirements: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class OwnershipTransferPolicy:
default_expiry_days: int = 7
min_expiry_days: int = 1
max_expiry_days: int = 30
recovery_assurance_profile: str = "standard"
recovery_required_approvals: int = 2
recovery_delay_hours: int = 24
recent_authentication_required: bool = True
def __post_init__(self) -> None:
if not 1 <= self.min_expiry_days <= self.default_expiry_days <= self.max_expiry_days:
raise ValueError("Ownership transfer expiry policy is inconsistent")
if self.recovery_required_approvals < 1:
raise ValueError("Ownership recovery requires at least one approval")
if self.recovery_delay_hours < 0:
raise ValueError("Ownership recovery delay cannot be negative")
@classmethod
def development(cls) -> OwnershipTransferPolicy:
return cls(
recovery_assurance_profile="development-single-admin",
recovery_required_approvals=1,
recovery_delay_hours=0,
recent_authentication_required=False,
)
@runtime_checkable
class ResourceOwnershipProvider(Protocol):
def current_owner(
self,
session: object,
*,
tenant_id: str,
resource_id: str,
) -> OwnershipSubjectRef | None:
...
def authorize_ownership_action(
self,
session: object,
*,
tenant_id: str,
resource_id: str,
action: str,
actor: OwnershipSubjectRef,
current_owner: OwnershipSubjectRef,
target_owner: OwnershipSubjectRef,
) -> OwnershipActionDecision:
...
def apply_owner(
self,
session: object,
*,
tenant_id: str,
resource_id: str,
expected_owner: OwnershipSubjectRef,
target_owner: OwnershipSubjectRef,
actor: OwnershipSubjectRef,
reason: str | None,
) -> None:
...
@dataclass(frozen=True, slots=True)
class OwnershipProviderRegistration:
resource_type: str
provider: ResourceOwnershipProvider
module_id: str | None = None
class OwnershipTransfer(Base, TimestampMixin):
__tablename__ = "core_ownership_transfers"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"resource_module",
"idempotency_key",
name="uq_core_ownership_transfer_idempotency",
),
Index(
"ix_core_ownership_transfer_resource",
"tenant_id",
"resource_module",
"resource_type",
"resource_id",
"status",
),
Index(
"ix_core_ownership_transfer_expiry",
"status",
"expires_at",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
resource_module: Mapped[str] = mapped_column(String(100), nullable=False)
resource_type: Mapped[str] = mapped_column(String(100), nullable=False)
resource_id: Mapped[str] = mapped_column(String(255), nullable=False)
kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
status: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
current_owner_type: Mapped[str] = mapped_column(String(40), nullable=False)
current_owner_id: Mapped[str] = mapped_column(String(255), nullable=False)
target_owner_type: Mapped[str] = mapped_column(String(40), nullable=False)
target_owner_id: Mapped[str] = mapped_column(String(255), nullable=False)
initiated_by_type: Mapped[str] = mapped_column(String(40), nullable=False)
initiated_by_id: Mapped[str] = mapped_column(String(255), nullable=False)
owner_approved_by_type: Mapped[str | None] = mapped_column(String(40))
owner_approved_by_id: Mapped[str | None] = mapped_column(String(255))
target_accepted_by_type: Mapped[str | None] = mapped_column(String(40))
target_accepted_by_id: Mapped[str | None] = mapped_column(String(255))
reason: Mapped[str | None] = mapped_column(Text)
assurance_profile: Mapped[str | None] = mapped_column(String(80))
required_approvals: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
approvals: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
decisions: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(200), nullable=False)
canonical_request_hash: Mapped[str] = mapped_column(String(64), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
execute_after: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
declined_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
expired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
metadata_: Mapped[dict[str, Any]] = mapped_column(
"metadata",
JSON,
default=dict,
nullable=False,
)
def start_owner_initiated_transfer(
session: Session,
*,
tenant_id: str,
resource: OwnershipResourceRef,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
target_owner: OwnershipSubjectRef,
idempotency_key: str,
reason: str | None = None,
expiry_days: int | None = None,
policy: OwnershipTransferPolicy = OwnershipTransferPolicy(),
now: datetime | None = None,
) -> OwnershipTransfer:
return _start_transfer(
session,
tenant_id=tenant_id,
resource=resource,
provider=provider,
actor=actor,
target_owner=target_owner,
kind=OwnershipTransferKind.OWNER_INITIATED,
initial_status=OwnershipTransferStatus.AWAITING_TARGET_ACCEPTANCE,
authorization_action="propose_transfer",
idempotency_key=idempotency_key,
reason=reason,
expiry_days=expiry_days,
policy=policy,
now=now,
assurance_profile=None,
required_approvals=1,
execute_after=None,
metadata={},
)
def request_ownership(
session: Session,
*,
tenant_id: str,
resource: OwnershipResourceRef,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
target_owner: OwnershipSubjectRef | None = None,
idempotency_key: str,
reason: str | None = None,
expiry_days: int | None = None,
policy: OwnershipTransferPolicy = OwnershipTransferPolicy(),
now: datetime | None = None,
) -> OwnershipTransfer:
requested_owner = target_owner or actor
return _start_transfer(
session,
tenant_id=tenant_id,
resource=resource,
provider=provider,
actor=actor,
target_owner=requested_owner,
kind=OwnershipTransferKind.TARGET_REQUESTED,
initial_status=OwnershipTransferStatus.AWAITING_OWNER_APPROVAL,
authorization_action="request_ownership",
idempotency_key=idempotency_key,
reason=reason,
expiry_days=expiry_days,
policy=policy,
now=now,
assurance_profile=None,
required_approvals=1,
execute_after=None,
metadata={},
)
def approve_ownership_request(
session: Session,
*,
transfer: OwnershipTransfer,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
now: datetime | None = None,
) -> OwnershipTransfer:
effective_now = _utc(now)
_require_active(session, transfer, effective_now)
if (
transfer.kind != OwnershipTransferKind.TARGET_REQUESTED.value
or transfer.status != OwnershipTransferStatus.AWAITING_OWNER_APPROVAL.value
):
raise OwnershipTransferError("Ownership request is not awaiting owner approval")
_authorize(provider, session, transfer, actor, "approve_requested_transfer")
transfer.owner_approved_by_type = actor.type
transfer.owner_approved_by_id = actor.id
transfer.status = OwnershipTransferStatus.AWAITING_TARGET_ACCEPTANCE.value
_touch(transfer)
_record_decision(
transfer,
action="owner_approved",
actor=actor,
decided_at=effective_now,
)
_emit_transfer_event(session, transfer, "owner_approved", actor)
session.flush()
return transfer
def accept_ownership_transfer(
session: Session,
*,
transfer: OwnershipTransfer,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
now: datetime | None = None,
) -> OwnershipTransfer:
effective_now = _utc(now)
_require_active(session, transfer, effective_now)
if transfer.status != OwnershipTransferStatus.AWAITING_TARGET_ACCEPTANCE.value:
raise OwnershipTransferError("Ownership transfer is not awaiting target acceptance")
action = (
"accept_group_transfer"
if transfer.target_owner_type == "group"
else "accept_transfer"
)
_authorize(provider, session, transfer, actor, action)
current_owner = _owner_ref(transfer, target=False)
target_owner = _owner_ref(transfer, target=True)
provider.apply_owner(
session,
tenant_id=transfer.tenant_id,
resource_id=transfer.resource_id,
expected_owner=current_owner,
target_owner=target_owner,
actor=actor,
reason=transfer.reason,
)
transfer.target_accepted_by_type = actor.type
transfer.target_accepted_by_id = actor.id
transfer.status = OwnershipTransferStatus.COMPLETED.value
transfer.completed_at = effective_now
_touch(transfer)
_record_decision(
transfer,
action="accepted",
actor=actor,
decided_at=effective_now,
)
_emit_transfer_event(session, transfer, "completed", actor)
session.flush()
return transfer
def decline_ownership_transfer(
session: Session,
*,
transfer: OwnershipTransfer,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
now: datetime | None = None,
) -> OwnershipTransfer:
effective_now = _utc(now)
_require_active(session, transfer, effective_now)
_authorize(provider, session, transfer, actor, "decline_transfer")
transfer.status = OwnershipTransferStatus.DECLINED.value
transfer.declined_at = effective_now
_touch(transfer)
_record_decision(
transfer,
action="declined",
actor=actor,
decided_at=effective_now,
)
_emit_transfer_event(session, transfer, "declined", actor)
session.flush()
return transfer
def cancel_ownership_transfer(
session: Session,
*,
transfer: OwnershipTransfer,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
now: datetime | None = None,
) -> OwnershipTransfer:
effective_now = _utc(now)
_require_active(session, transfer, effective_now)
_authorize(provider, session, transfer, actor, "cancel_transfer")
transfer.status = OwnershipTransferStatus.CANCELLED.value
transfer.cancelled_at = effective_now
_touch(transfer)
_record_decision(
transfer,
action="cancelled",
actor=actor,
decided_at=effective_now,
)
_emit_transfer_event(session, transfer, "cancelled", actor)
session.flush()
return transfer
def start_administrative_recovery(
session: Session,
*,
tenant_id: str,
resource: OwnershipResourceRef,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
target_owner: OwnershipSubjectRef,
idempotency_key: str,
reason: str,
policy: OwnershipTransferPolicy = OwnershipTransferPolicy(),
now: datetime | None = None,
) -> OwnershipTransfer:
if not reason.strip():
raise OwnershipTransferError("Administrative recovery requires a reason")
if policy.recent_authentication_required and not actor.recently_authenticated:
raise OwnershipAuthorizationError(
"Administrative recovery requires recent authentication"
)
effective_now = _utc(now)
transfer = _start_transfer(
session,
tenant_id=tenant_id,
resource=resource,
provider=provider,
actor=actor,
target_owner=target_owner,
kind=OwnershipTransferKind.ADMINISTRATIVE_RECOVERY,
initial_status=OwnershipTransferStatus.AWAITING_RECOVERY_APPROVALS,
authorization_action="request_recovery",
idempotency_key=idempotency_key,
reason=reason,
expiry_days=policy.default_expiry_days,
policy=policy,
now=effective_now,
assurance_profile=policy.recovery_assurance_profile,
required_approvals=policy.recovery_required_approvals,
execute_after=effective_now
+ timedelta(hours=policy.recovery_delay_hours),
metadata={
"recent_authentication_required": (
policy.recent_authentication_required
),
"encryption_keys_included": False,
},
)
return transfer
def approve_administrative_recovery(
session: Session,
*,
transfer: OwnershipTransfer,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
now: datetime | None = None,
) -> OwnershipTransfer:
effective_now = _utc(now)
_require_active(session, transfer, effective_now)
_require_recovery_authentication(transfer, actor)
if (
transfer.kind != OwnershipTransferKind.ADMINISTRATIVE_RECOVERY.value
or transfer.status
not in {
OwnershipTransferStatus.AWAITING_RECOVERY_APPROVALS.value,
OwnershipTransferStatus.RECOVERY_SCHEDULED.value,
}
):
raise OwnershipTransferError("Ownership recovery is not awaiting approval")
_authorize(provider, session, transfer, actor, "approve_recovery")
approvals = list(transfer.approvals or [])
if any(
item.get("actor_type") == actor.type and item.get("actor_id") == actor.id
for item in approvals
):
return transfer
approvals.append(
{
"actor_type": actor.type,
"actor_id": actor.id,
"approved_at": effective_now.isoformat(),
}
)
transfer.approvals = approvals
if len(approvals) >= transfer.required_approvals:
transfer.status = OwnershipTransferStatus.RECOVERY_SCHEDULED.value
_touch(transfer)
_record_decision(
transfer,
action="recovery_approved",
actor=actor,
decided_at=effective_now,
details={"approval_count": len(approvals)},
)
_emit_transfer_event(session, transfer, "recovery_approved", actor)
session.flush()
return transfer
def execute_administrative_recovery(
session: Session,
*,
transfer: OwnershipTransfer,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
now: datetime | None = None,
) -> OwnershipTransfer:
effective_now = _utc(now)
_require_active(session, transfer, effective_now)
_require_recovery_authentication(transfer, actor)
if transfer.status != OwnershipTransferStatus.RECOVERY_SCHEDULED.value:
raise OwnershipTransferError("Ownership recovery has not reached its approval quorum")
if transfer.execute_after and _utc(transfer.execute_after) > effective_now:
raise OwnershipTransferError("Ownership recovery assurance delay has not elapsed")
_authorize(provider, session, transfer, actor, "execute_recovery")
provider.apply_owner(
session,
tenant_id=transfer.tenant_id,
resource_id=transfer.resource_id,
expected_owner=_owner_ref(transfer, target=False),
target_owner=_owner_ref(transfer, target=True),
actor=actor,
reason=transfer.reason,
)
transfer.status = OwnershipTransferStatus.COMPLETED.value
transfer.completed_at = effective_now
_touch(transfer)
_record_decision(
transfer,
action="recovery_executed",
actor=actor,
decided_at=effective_now,
)
_emit_transfer_event(session, transfer, "recovery_completed", actor)
session.flush()
return transfer
def expire_due_ownership_transfers(
session: Session,
*,
now: datetime | None = None,
limit: int = 250,
) -> int:
effective_now = _utc(now)
rows = (
session.query(OwnershipTransfer)
.filter(
OwnershipTransfer.status.notin_(
sorted(TERMINAL_OWNERSHIP_TRANSFER_STATUSES)
),
OwnershipTransfer.expires_at <= effective_now,
)
.order_by(OwnershipTransfer.expires_at.asc(), OwnershipTransfer.id.asc())
.limit(limit)
.all()
)
for transfer in rows:
transfer.status = OwnershipTransferStatus.EXPIRED.value
transfer.expired_at = effective_now
_touch(transfer)
_record_decision(
transfer,
action="expired",
actor=None,
decided_at=effective_now,
)
_emit_transfer_event(session, transfer, "expired", None)
session.flush()
return len(rows)
def _start_transfer(
session: Session,
*,
tenant_id: str,
resource: OwnershipResourceRef,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
target_owner: OwnershipSubjectRef,
kind: OwnershipTransferKind,
initial_status: OwnershipTransferStatus,
authorization_action: str,
idempotency_key: str,
reason: str | None,
expiry_days: int | None,
policy: OwnershipTransferPolicy,
now: datetime | None,
assurance_profile: str | None,
required_approvals: int,
execute_after: datetime | None,
metadata: dict[str, Any],
) -> OwnershipTransfer:
if not resource.module_id or resource.module_id != resource.module_id.strip():
raise OwnershipTransferError("Ownership resource module id is invalid")
clean_key = idempotency_key.strip()
if not clean_key or len(clean_key) > 200:
raise OwnershipTransferError("A bounded ownership idempotency key is required")
effective_now = _utc(now)
days = policy.default_expiry_days if expiry_days is None else expiry_days
if not policy.min_expiry_days <= days <= policy.max_expiry_days:
raise OwnershipTransferError(
"Ownership transfer expiry is outside the effective policy"
)
current_owner = provider.current_owner(
session,
tenant_id=tenant_id,
resource_id=resource.resource_id,
)
if current_owner is None:
raise OwnershipTransferError("Owned resource was not found")
if current_owner.type == target_owner.type and current_owner.id == target_owner.id:
raise OwnershipTransferError("Target is already the resource owner")
_authorize_direct(
provider,
session,
tenant_id=tenant_id,
resource_id=resource.resource_id,
current_owner=current_owner,
target_owner=target_owner,
actor=actor,
action=authorization_action,
)
request_hash = _request_hash(
tenant_id=tenant_id,
resource=resource,
kind=kind,
current_owner=current_owner,
target_owner=target_owner,
actor=actor,
reason=reason,
expiry_days=days,
assurance_profile=assurance_profile,
required_approvals=required_approvals,
execute_after=execute_after,
metadata=metadata,
)
existing = (
session.query(OwnershipTransfer)
.filter(
OwnershipTransfer.tenant_id == tenant_id,
OwnershipTransfer.resource_module == resource.module_id,
OwnershipTransfer.idempotency_key == clean_key,
)
.one_or_none()
)
if existing is not None:
if existing.canonical_request_hash != request_hash:
raise OwnershipIdempotencyConflict(
"Ownership idempotency key is already bound to another request"
)
return existing
transfer = OwnershipTransfer(
tenant_id=tenant_id,
resource_module=resource.module_id,
resource_type=resource.resource_type,
resource_id=resource.resource_id,
kind=kind.value,
status=initial_status.value,
current_owner_type=current_owner.type,
current_owner_id=current_owner.id,
target_owner_type=target_owner.type,
target_owner_id=target_owner.id,
initiated_by_type=actor.type,
initiated_by_id=actor.id,
reason=reason.strip() if reason else None,
assurance_profile=assurance_profile,
required_approvals=required_approvals,
approvals=[],
decisions=[],
idempotency_key=clean_key,
canonical_request_hash=request_hash,
expires_at=effective_now + timedelta(days=days),
execute_after=execute_after,
metadata_=dict(metadata),
)
session.add(transfer)
session.flush()
_record_decision(
transfer,
action="started",
actor=actor,
decided_at=effective_now,
details={
"kind": kind.value,
"initial_status": initial_status.value,
"assurance_profile": assurance_profile,
"required_approvals": required_approvals,
},
)
_emit_transfer_event(session, transfer, "started", actor)
return transfer
def _authorize(
provider: ResourceOwnershipProvider,
session: Session,
transfer: OwnershipTransfer,
actor: OwnershipSubjectRef,
action: str,
) -> None:
_authorize_direct(
provider,
session,
tenant_id=transfer.tenant_id,
resource_id=transfer.resource_id,
current_owner=_owner_ref(transfer, target=False),
target_owner=_owner_ref(transfer, target=True),
actor=actor,
action=action,
)
def _authorize_direct(
provider: ResourceOwnershipProvider,
session: Session,
*,
tenant_id: str,
resource_id: str,
current_owner: OwnershipSubjectRef,
target_owner: OwnershipSubjectRef,
actor: OwnershipSubjectRef,
action: str,
) -> None:
decision = provider.authorize_ownership_action(
session,
tenant_id=tenant_id,
resource_id=resource_id,
action=action,
actor=actor,
current_owner=current_owner,
target_owner=target_owner,
)
if not decision.allowed:
raise OwnershipAuthorizationError(
decision.reason or f"Ownership action is not allowed: {action}"
)
def _require_active(
session: Session,
transfer: OwnershipTransfer,
now: datetime,
) -> None:
if transfer.status in TERMINAL_OWNERSHIP_TRANSFER_STATUSES:
raise OwnershipTransferError("Ownership transfer is already final")
if _utc(transfer.expires_at) <= now:
transfer.status = OwnershipTransferStatus.EXPIRED.value
transfer.expired_at = now
_touch(transfer)
_record_decision(
transfer,
action="expired",
actor=None,
decided_at=now,
)
_emit_transfer_event(session, transfer, "expired", None)
raise OwnershipTransferExpired("Ownership transfer has expired")
def _require_recovery_authentication(
transfer: OwnershipTransfer,
actor: OwnershipSubjectRef,
) -> None:
if (
bool((transfer.metadata_ or {}).get("recent_authentication_required"))
and not actor.recently_authenticated
):
raise OwnershipAuthorizationError(
"Administrative recovery requires recent authentication"
)
def _owner_ref(
transfer: OwnershipTransfer,
*,
target: bool,
) -> OwnershipSubjectRef:
return OwnershipSubjectRef(
type=transfer.target_owner_type if target else transfer.current_owner_type,
id=transfer.target_owner_id if target else transfer.current_owner_id,
)
def _touch(transfer: OwnershipTransfer) -> None:
transfer.revision = int(transfer.revision or 0) + 1
def _record_decision(
transfer: OwnershipTransfer,
*,
action: str,
actor: OwnershipSubjectRef | None,
decided_at: datetime,
details: dict[str, Any] | None = None,
) -> None:
decisions = list(transfer.decisions or [])
decisions.append(
{
"sequence": len(decisions) + 1,
"action": action,
"actor_type": actor.type if actor else None,
"actor_id": actor.id if actor else None,
"decided_at": _utc(decided_at).isoformat(),
"status": transfer.status,
"details": dict(details or {}),
}
)
transfer.decisions = decisions
def _utc(value: datetime | None) -> datetime:
if value is None:
return utcnow()
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _request_hash(
*,
tenant_id: str,
resource: OwnershipResourceRef,
kind: OwnershipTransferKind,
current_owner: OwnershipSubjectRef,
target_owner: OwnershipSubjectRef,
actor: OwnershipSubjectRef,
reason: str | None,
expiry_days: int,
assurance_profile: str | None,
required_approvals: int,
execute_after: datetime | None,
metadata: dict[str, Any],
) -> str:
payload = {
"tenant_id": tenant_id,
"resource": {
"module_id": resource.module_id,
"type": resource.resource_type,
"id": resource.resource_id,
},
"kind": kind.value,
"current_owner": {"type": current_owner.type, "id": current_owner.id},
"target_owner": {"type": target_owner.type, "id": target_owner.id},
"actor": {"type": actor.type, "id": actor.id},
"reason": reason.strip() if reason else None,
"expiry_days": expiry_days,
"assurance_profile": assurance_profile,
"required_approvals": required_approvals,
"execute_after": (
_utc(execute_after).isoformat()
if execute_after is not None
else None
),
"metadata": metadata,
}
encoded = json.dumps(
payload,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _emit_transfer_event(
session: Session,
transfer: OwnershipTransfer,
action: str,
actor: OwnershipSubjectRef | None,
) -> None:
from govoplan_core.core.events import (
EventActorRef,
EventObjectRef,
EventTenantRef,
PlatformEvent,
emit_platform_event,
)
emit_platform_event(
session,
PlatformEvent(
type=f"core.ownership_transfer.{action}.v1",
module_id="core",
tenant=EventTenantRef(id=transfer.tenant_id),
actor=EventActorRef(type=actor.type, id=actor.id) if actor else None,
resource=EventObjectRef(
type="ownership_transfer",
id=transfer.id,
),
classification="confidential",
payload={
"schema_version": 1,
"resource_module": transfer.resource_module,
"resource_type": transfer.resource_type,
"resource_id": transfer.resource_id,
"kind": transfer.kind,
"status": transfer.status,
"current_owner_type": transfer.current_owner_type,
"current_owner_id": transfer.current_owner_id,
"target_owner_type": transfer.target_owner_type,
"target_owner_id": transfer.target_owner_id,
"assurance_profile": transfer.assurance_profile,
"required_approvals": transfer.required_approvals,
"approval_count": len(transfer.approvals or []),
"encryption_keys_included": False,
},
),
)
__all__ = [
"OwnershipActionDecision",
"OwnershipAuthorizationError",
"OwnershipIdempotencyConflict",
"OwnershipProviderRegistration",
"OwnershipResourceRef",
"OwnershipSubjectRef",
"OwnershipTransfer",
"OwnershipTransferError",
"OwnershipTransferExpired",
"OwnershipTransferKind",
"OwnershipTransferPolicy",
"OwnershipTransferStatus",
"ResourceOwnershipProvider",
"accept_ownership_transfer",
"approve_administrative_recovery",
"approve_ownership_request",
"cancel_ownership_transfer",
"decline_ownership_transfer",
"execute_administrative_recovery",
"expire_due_ownership_transfers",
"request_ownership",
"start_administrative_recovery",
"start_owner_initiated_transfer",
]
+169
View File
@@ -0,0 +1,169 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable
CAPABILITY_ACCESS_PEOPLE_SEARCH = "access.people_search"
CAPABILITY_ADDRESSES_PEOPLE_SEARCH = "addresses.people_search"
# Identity search is deliberately absent here. ``identity.search`` is an
# instance-wide canonical-identity directory and has no tenant/principal
# visibility contract. Ordinary task pickers must use principal-aware search
# providers instead.
DEFAULT_PEOPLE_SEARCH_CAPABILITIES = (
CAPABILITY_ACCESS_PEOPLE_SEARCH,
CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
)
class PeopleSearchError(ValueError):
"""Stable, non-diagnostic error raised by people-search providers."""
@dataclass(frozen=True, slots=True)
class PersonSearchCandidate:
"""A policy-filtered selection target returned by a directory provider."""
selection_key: str
kind: str
reference_id: str
display_name: str
email: str | None = None
source_module: str | None = None
source_label: str | None = None
source_ref: str | None = None
source_revision: str | None = None
description: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PeopleSearchGroup:
key: str
label: str
candidates: tuple[PersonSearchCandidate, ...] = ()
@runtime_checkable
class PeopleSearchProvider(Protocol):
"""Server-side, principal-aware people/delivery-target search.
Implementations must derive visibility from ``principal`` and may only
return records the principal can discover in its active tenant and policy
context. Callers remain responsible for authorizing the task for which the
picker is used (for example, creating a scheduling request).
"""
def search_people(
self,
session: object,
principal: object,
*,
query: str,
limit: int = 25,
) -> Sequence[PeopleSearchGroup]:
...
def people_search_providers(
registry: object | None,
*,
capability_names: Sequence[str] = DEFAULT_PEOPLE_SEARCH_CAPABILITIES,
) -> tuple[PeopleSearchProvider, ...]:
if registry is None or not hasattr(registry, "has_capability") or not hasattr(registry, "capability"):
return ()
providers: list[PeopleSearchProvider] = []
for capability_name in capability_names:
if not registry.has_capability(capability_name):
continue
capability = registry.capability(capability_name)
if isinstance(capability, PeopleSearchProvider):
providers.append(capability)
return tuple(providers)
def search_visible_people(
registry: object | None,
session: object,
principal: object,
*,
query: str,
limit: int = 25,
capability_names: Sequence[str] = DEFAULT_PEOPLE_SEARCH_CAPABILITIES,
) -> tuple[PeopleSearchGroup, ...]:
"""Collect grouped results without importing optional feature modules.
``limit`` is enforced per provider so one directory cannot starve another
result group. Providers are expected to return at most that many candidates
in total. Duplicate group keys are merged and candidate selection keys are
de-duplicated while preserving provider order.
"""
normalized_query = str(query or "").strip()
normalized_limit = max(1, min(int(limit), 100))
ordered_group_keys: list[str] = []
labels: dict[str, str] = {}
candidates_by_group: dict[str, list[PersonSearchCandidate]] = {}
candidate_keys_by_group: dict[str, set[str]] = {}
for provider in people_search_providers(registry, capability_names=capability_names):
groups = provider.search_people(
session,
principal,
query=normalized_query,
limit=normalized_limit,
)
provider_candidate_count = 0
for group in groups:
if not group.key:
continue
if group.key not in candidates_by_group:
ordered_group_keys.append(group.key)
labels[group.key] = group.label
candidates_by_group[group.key] = []
candidate_keys_by_group[group.key] = set()
for candidate in group.candidates:
if provider_candidate_count >= normalized_limit:
break
if not candidate.selection_key or candidate.selection_key in candidate_keys_by_group[group.key]:
continue
candidate_keys_by_group[group.key].add(candidate.selection_key)
candidates_by_group[group.key].append(candidate)
provider_candidate_count += 1
return tuple(
PeopleSearchGroup(
key=group_key,
label=labels[group_key],
candidates=tuple(candidates_by_group[group_key]),
)
for group_key in ordered_group_keys
if candidates_by_group[group_key]
)
def person_selection_key(kind: str, reference_id: str, *, email: str | None = None) -> str:
normalized_kind = str(kind).strip().casefold()
normalized_reference = str(reference_id).strip()
normalized_email = str(email or "").strip().casefold()
if not normalized_kind or not normalized_reference:
raise ValueError("Person selection keys require a kind and reference id.")
return ":".join(part for part in (normalized_kind, normalized_reference, normalized_email) if part)
__all__ = [
"CAPABILITY_ACCESS_PEOPLE_SEARCH",
"CAPABILITY_ADDRESSES_PEOPLE_SEARCH",
"DEFAULT_PEOPLE_SEARCH_CAPABILITIES",
"PeopleSearchError",
"PeopleSearchGroup",
"PeopleSearchProvider",
"PersonSearchCandidate",
"people_search_providers",
"person_selection_key",
"search_visible_people",
]
+273 -31
View File
@@ -4,13 +4,55 @@ from dataclasses import dataclass, field
from typing import Any, Iterable, Literal, Mapping, Protocol, cast, runtime_checkable
from urllib.parse import quote, unquote
from govoplan_core.core.access import PrincipalRef
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
SchedulingParticipantVisibility = Literal["aggregates_only", "names_and_statuses"]
DefinitionScopeType = Literal["system", "tenant", "group", "user"]
DefinitionKind = Literal["flow", "template"]
DefinitionGovernanceAction = Literal[
"view",
"edit",
"run",
"reuse",
"derive",
"automate",
]
ViewGovernanceAction = Literal[
"view",
"select",
"assign",
"edit",
"derive",
"workflow_activate",
]
FunctionAssignmentChangeKind = Literal["request", "grant"]
FunctionAssignmentGovernanceAction = Literal[
"submit",
"approve_holder",
"approve_authority",
"accept_recipient",
"request_changes",
"respond",
"reject",
"withdraw",
"recover",
"apply",
]
CAPABILITY_POLICY_PRIVACY_RETENTION = "policy.privacyRetention"
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY = "policy.schedulingParticipantPrivacy"
CAPABILITY_POLICY_DEFINITION_GOVERNANCE = "policy.definitionGovernance"
CAPABILITY_POLICY_VIEW_GOVERNANCE = "policy.viewGovernance"
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE = "policy.functionAssignmentGovernance"
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign")
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = (
"system",
"tenant",
"user",
"group",
"campaign",
)
def normalize_policy_scope_type(scope_type: str) -> PolicyScopeType:
@@ -30,7 +72,11 @@ class PolicySourceRef:
return policy_source_path(self.scope_type, self.scope_id)
def to_dict(self) -> dict[str, Any]:
return {"scope_type": self.scope_type, "scope_id": self.scope_id, "path": self.path}
return {
"scope_type": self.scope_type,
"scope_id": self.scope_id,
"path": self.path,
}
def policy_source_path(scope_type: str, scope_id: str | None = None) -> str:
@@ -40,7 +86,9 @@ def policy_source_path(scope_type: str, scope_id: str | None = None) -> str:
raise ValueError("System policy sources do not carry a scope_id")
return "system"
if not scope_id:
raise ValueError(f"{clean_scope.capitalize()} policy sources require a scope_id")
raise ValueError(
f"{clean_scope.capitalize()} policy sources require a scope_id"
)
return f"{clean_scope}:{quote(str(scope_id), safe='')}"
@@ -50,7 +98,9 @@ def parse_policy_source_path(path: str) -> PolicySourceRef:
return PolicySourceRef(scope_type="system")
scope_type, separator, encoded_scope_id = clean_path.partition(":")
if not separator:
raise ValueError("Policy source path must be system or <scope_type>:<url-encoded-scope-id>")
raise ValueError(
"Policy source path must be system or <scope_type>:<url-encoded-scope-id>"
)
clean_scope = normalize_policy_scope_type(scope_type)
if clean_scope == "system":
raise ValueError("System policy source path must be exactly system")
@@ -87,9 +137,13 @@ class PolicySourceStep:
policy_value = value.get("policy")
return cls(
scope_type=normalize_policy_scope_type(str(value.get("scope_type", ""))),
scope_id=str(value["scope_id"]) if value.get("scope_id") is not None else None,
scope_id=str(value["scope_id"])
if value.get("scope_id") is not None
else None,
label=str(value.get("label") or ""),
applied_fields=tuple(str(field) for field in (value.get("applied_fields") or ())),
applied_fields=tuple(
str(field) for field in (value.get("applied_fields") or ())
),
policy=policy_value if isinstance(policy_value, Mapping) else {},
)
@@ -128,6 +182,202 @@ class PolicyDecision:
}
@dataclass(frozen=True, slots=True)
class FunctionAssignmentGovernanceRequest:
tenant_id: str
kind: FunctionAssignmentChangeKind
action: FunctionAssignmentGovernanceAction
function_id: str
actor: PrincipalRef
candidate_identity_id: str
candidate_account_id: str | None = None
current_state: str = "draft"
function_settings: Mapping[str, Any] = field(default_factory=dict)
context: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class FunctionAssignmentGovernanceDecision:
allowed: bool
reason: str | None = None
profile: str = "unavailable"
required_steps: tuple[str, ...] = ()
authority_function_id: str | None = None
evidence_required: bool = False
recipient_acceptance_required: bool = False
separation_of_duties: bool = True
quorum: int = 1
maximum_validity_days: int | None = None
request_expiry_hours: int = 336
source_path: tuple[PolicySourceStep, ...] = ()
requirements: tuple[str, ...] = ()
details: Mapping[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"allowed": self.allowed,
"reason": self.reason,
"profile": self.profile,
"required_steps": list(self.required_steps),
"authority_function_id": self.authority_function_id,
"evidence_required": self.evidence_required,
"recipient_acceptance_required": (self.recipient_acceptance_required),
"separation_of_duties": self.separation_of_duties,
"quorum": self.quorum,
"maximum_validity_days": self.maximum_validity_days,
"request_expiry_hours": self.request_expiry_hours,
"source_path": [step.to_dict() for step in self.source_path],
"requirements": list(self.requirements),
"details": dict(self.details),
}
@runtime_checkable
class FunctionAssignmentGovernancePolicy(Protocol):
def resolve_function_assignment_action(
self,
session: object | None = None,
*,
request: FunctionAssignmentGovernanceRequest,
) -> FunctionAssignmentGovernanceDecision: ...
def function_assignment_governance_policy(
registry: object | None,
) -> FunctionAssignmentGovernancePolicy | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE)
):
return None
capability = registry.capability(CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE)
return (
capability
if isinstance(capability, FunctionAssignmentGovernancePolicy)
else None
)
@dataclass(frozen=True, slots=True)
class DefinitionScopeRef:
scope_type: DefinitionScopeType
scope_id: str | None = None
@property
def path(self) -> str:
return policy_source_path(self.scope_type, self.scope_id)
@dataclass(frozen=True, slots=True)
class DefinitionGovernanceRequest:
module_id: str
definition_ref: str
tenant_id: str
definition_scope: DefinitionScopeRef
target_scope: DefinitionScopeRef
definition_kind: DefinitionKind
action: DefinitionGovernanceAction
actor: PrincipalRef
status: str = "draft"
inherit_to_lower_scopes: bool = False
allow_run: bool = True
allow_reuse: bool = False
allow_automation: bool = False
context: Mapping[str, Any] = field(default_factory=dict)
@runtime_checkable
class DefinitionGovernancePolicy(Protocol):
def resolve_definition_action(
self,
session: object | None = None,
*,
request: DefinitionGovernanceRequest,
) -> PolicyDecision: ...
def definition_governance_policy(
registry: object | None,
) -> DefinitionGovernancePolicy | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_POLICY_DEFINITION_GOVERNANCE)
):
return None
capability = registry.capability(CAPABILITY_POLICY_DEFINITION_GOVERNANCE)
return capability if isinstance(capability, DefinitionGovernancePolicy) else None
@dataclass(frozen=True, slots=True)
class ViewGovernanceRequest:
tenant_id: str
action: ViewGovernanceAction
actor: PrincipalRef
target_scope: DefinitionScopeRef
view_id: str | None = None
candidate_view_ids: tuple[str, ...] = ()
candidate_surface_ids: tuple[str, ...] = ()
requested_surface_ids: tuple[str, ...] = ()
context: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ViewGovernanceDecision:
allowed: bool
reason: str | None = None
allowed_view_ids: frozenset[str] | None = None
visible_surface_ids: frozenset[str] | None = None
source_path: tuple[PolicySourceStep, ...] = ()
requirements: tuple[str, ...] = ()
diagnostics: tuple[Mapping[str, Any], ...] = ()
details: Mapping[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"allowed": self.allowed,
"reason": self.reason,
"allowed_view_ids": (
sorted(self.allowed_view_ids)
if self.allowed_view_ids is not None
else None
),
"visible_surface_ids": (
sorted(self.visible_surface_ids)
if self.visible_surface_ids is not None
else None
),
"source_path": [step.to_dict() for step in self.source_path],
"requirements": list(self.requirements),
"diagnostics": [dict(item) for item in self.diagnostics],
"details": dict(self.details),
}
@runtime_checkable
class ViewGovernancePolicy(Protocol):
def resolve_view_action(
self,
session: object | None = None,
*,
request: ViewGovernanceRequest,
) -> ViewGovernanceDecision: ...
def view_governance_policy(
registry: object | None,
) -> ViewGovernancePolicy | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_POLICY_VIEW_GOVERNANCE)
):
return None
capability = registry.capability(CAPABILITY_POLICY_VIEW_GOVERNANCE)
return capability if isinstance(capability, ViewGovernancePolicy) else None
@dataclass(frozen=True, slots=True)
class SchedulingParticipantPrivacyRequest:
"""Context for resolving what one Scheduling participant may see.
@@ -173,44 +423,32 @@ class SchedulingParticipantPrivacyPolicy(Protocol):
session: object,
*,
request: SchedulingParticipantPrivacyRequest,
) -> SchedulingParticipantPrivacyDecision:
...
) -> SchedulingParticipantPrivacyDecision: ...
@runtime_checkable
class PrivacyRetentionService(Protocol):
def privacy_policy_from_settings(self, *args: Any, **kwargs: Any) -> Any:
...
def privacy_policy_from_settings(self, *args: Any, **kwargs: Any) -> Any: ...
def privacy_policy_from_session(self, *args: Any, **kwargs: Any) -> Any:
...
def privacy_policy_from_session(self, *args: Any, **kwargs: Any) -> Any: ...
def set_privacy_policy(self, *args: Any, **kwargs: Any) -> Any:
...
def set_privacy_policy(self, *args: Any, **kwargs: Any) -> Any: ...
def effective_privacy_policy(self, *args: Any, **kwargs: Any) -> Any:
...
def effective_privacy_policy(self, *args: Any, **kwargs: Any) -> Any: ...
def parent_privacy_policy(self, *args: Any, **kwargs: Any) -> Any:
...
def parent_privacy_policy(self, *args: Any, **kwargs: Any) -> Any: ...
def effective_privacy_policy_sources(self, *args: Any, **kwargs: Any) -> Any:
...
def effective_privacy_policy_sources(self, *args: Any, **kwargs: Any) -> Any: ...
def parent_privacy_policy_sources(self, *args: Any, **kwargs: Any) -> Any:
...
def parent_privacy_policy_sources(self, *args: Any, **kwargs: Any) -> Any: ...
def get_privacy_policy_for_scope(self, *args: Any, **kwargs: Any) -> Any:
...
def get_privacy_policy_for_scope(self, *args: Any, **kwargs: Any) -> Any: ...
def set_privacy_policy_for_scope(self, *args: Any, **kwargs: Any) -> Any:
...
def set_privacy_policy_for_scope(self, *args: Any, **kwargs: Any) -> Any: ...
def sanitize_audit_details_for_policy(self, *args: Any, **kwargs: Any) -> Any:
...
def sanitize_audit_details_for_policy(self, *args: Any, **kwargs: Any) -> Any: ...
def apply_retention_policy(self, *args: Any, **kwargs: Any) -> Any:
...
def apply_retention_policy(self, *args: Any, **kwargs: Any) -> Any: ...
def scheduling_participant_privacy_policy(
@@ -223,4 +461,8 @@ def scheduling_participant_privacy_policy(
if not registry.has_capability(CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY):
return None
capability = registry.capability(CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY)
return capability if isinstance(capability, SchedulingParticipantPrivacyPolicy) else None
return (
capability
if isinstance(capability, SchedulingParticipantPrivacyPolicy)
else None
)
+82
View File
@@ -73,6 +73,18 @@ class PollOptionUpdateCommand:
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PollOptionOrderCommand:
"""Complete active option order supplied by an owning module.
Providers must reject partial, duplicate, or unknown option collections.
An exact repeat is an idempotent no-op. Reordering changes positions only;
option identities and their existing answers remain valid.
"""
option_ids: tuple[str, ...]
@dataclass(frozen=True, slots=True)
class PollInvitationCommand:
respondent_id: str | None = None
@@ -98,6 +110,22 @@ class PollSubmitResponseCommand:
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PollResponseRetirementCommand:
"""Retire live responses while preserving their auditable history.
Owning modules provide every server-trusted identity that can refer to the
participant. Providers must soft-delete matching live responses, retain
their answers, and treat an exact ``idempotency_key`` replay as a no-op.
"""
respondent_ids: tuple[str, ...] = ()
invitation_id: str | None = None
reason: str = "participant_removed"
idempotency_key: str = ""
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PollOptionRef:
id: str
@@ -138,6 +166,16 @@ class PollResponseRef:
answers: tuple[PollAnswerRef, ...] = ()
@dataclass(frozen=True, slots=True)
class PollResponseRetirementRef:
"""Outcome of an auditable response-retirement request."""
response_ids: tuple[str, ...] = ()
retired_at: datetime | None = None
newly_retired_count: int = 0
replayed: bool = False
@runtime_checkable
class PollSchedulingProvider(Protocol):
def create_poll(
@@ -183,6 +221,18 @@ class PollSchedulingProvider(Protocol):
...
def reorder_options(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
command: PollOptionOrderCommand,
) -> PollRef:
"""Atomically replace the complete active option order."""
...
def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
...
@@ -251,6 +301,21 @@ class PollResponseSubmissionProvider(Protocol):
...
@runtime_checkable
class PollResponseRetirementProvider(Protocol):
"""Optional extension for owning modules that remove participants."""
def retire_responses(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
command: PollResponseRetirementCommand,
) -> PollResponseRetirementRef:
...
def poll_scheduling_provider(registry: object | None) -> PollSchedulingProvider | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
@@ -265,3 +330,20 @@ def poll_response_submission_provider(
) -> PollResponseSubmissionProvider | None:
provider = poll_scheduling_provider(registry)
return provider if isinstance(provider, PollResponseSubmissionProvider) else None
def poll_response_retirement_provider(
registry: object | None,
) -> PollResponseRetirementProvider | None:
"""Resolve response retirement without making it mandatory for Poll v1 providers."""
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_POLL_SCHEDULING):
return None
capability = registry.capability(CAPABILITY_POLL_SCHEDULING)
return (
capability
if isinstance(capability, PollResponseRetirementProvider)
else None
)
@@ -0,0 +1,272 @@
from __future__ import annotations
import hashlib
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
from govoplan_core.core.poll import (
PollAnswerRequest,
PollInvitationRef,
PollOptionRequest,
PollResponseRef,
)
CAPABILITY_POLL_PARTICIPATION_GATEWAY = "poll.participation_gateway"
# Policy-attestation identifier, not a credential or credential default.
ANONYMOUS_PASSWORD_REQUIREMENT = "anonymous_password" # nosec B105 # noqa: S105
PARTICIPATION_POLICY_VERSION = 1
def participation_token_fingerprint(token: str) -> str:
"""Return a non-reversible identifier suitable for audit/throttle keys."""
return hashlib.sha256(token.encode("utf-8")).hexdigest()
@dataclass(frozen=True, slots=True)
class PollResponseGatewayRef:
"""Stable identity of the module resource governing a participation link."""
module_id: str
resource_type: str
resource_id: str
@dataclass(frozen=True, slots=True)
class PollParticipationPolicy:
"""Generic response rules snapshotted onto one signed invitation.
``single_choice`` treats every non-``unavailable`` availability answer as
a selection. Capacity is reserved only by ``available`` answers (and by
selected answers for non-availability polls), so ``maybe`` never consumes
a place.
"""
version: int = PARTICIPATION_POLICY_VERSION
single_choice: bool = False
allow_maybe: bool = True
max_participants_per_option: int | None = None
allow_comments: bool = False
participant_email_required: bool = False
anonymous_password_required: bool = False
@dataclass(frozen=True, slots=True)
class PollGovernedInvitationCommand:
gateway: PollResponseGatewayRef
policy: PollParticipationPolicy
respondent_id: str | None = None
respondent_label: str | None = None
email: str | None = None
expires_at: datetime | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PollGovernedResponseCommand:
"""Submission already authorized by the named in-process gateway.
Passwords never cross this boundary. A gateway that owns a password
verifier reports the completed check through ``verified_requirements``.
Poll independently re-enforces the remaining snapshotted rules while
holding its Poll-row lock.
"""
respondent_id: str | None = None
respondent_label: str | None = None
participant_email: str | None = None
participant_is_authenticated: bool = False
answers: tuple[PollAnswerRequest, ...] = ()
comment: str | None = None
verified_requirements: frozenset[str] = frozenset()
idempotency_key: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PollGovernedResponseRef:
response: PollResponseRef
participant_email: str | None = None
comment: str | None = None
replayed: bool = False
@dataclass(frozen=True, slots=True)
class PollOptionMutationRef:
id: str
position: int
replayed: bool = False
invalidated_response_count: int = 0
@dataclass(frozen=True, slots=True)
class PollInvitationRevocationRef:
id: str
revoked_at: datetime
replayed: bool = False
@dataclass(frozen=True, slots=True)
class PollInvitationExpiryRef:
id: str
expires_at: datetime | None
replayed: bool = False
@dataclass(frozen=True, slots=True)
class PollParticipationContextRef:
invitation_id: str
tenant_id: str
poll_id: str
gateway: PollResponseGatewayRef
policy: PollParticipationPolicy
respondent_id: str | None = None
respondent_label: str | None = None
email: str | None = None
response: PollGovernedResponseRef | None = None
@runtime_checkable
class PollParticipationGatewayProvider(Protocol):
def create_governed_invitation(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
command: PollGovernedInvitationCommand,
) -> PollInvitationRef:
...
def resolve_participation(
self,
session: object,
*,
token: str,
gateway: PollResponseGatewayRef,
respondent_id: str | None = None,
participant_email: str | None = None,
participant_is_authenticated: bool = False,
verified_requirements: frozenset[str] = frozenset(),
) -> PollParticipationContextRef:
"""Resolve and prefill one valid invitation for the exact gateway."""
...
def submit_governed_response(
self,
session: object,
*,
token: str,
gateway: PollResponseGatewayRef,
command: PollGovernedResponseCommand,
) -> PollGovernedResponseRef:
...
def resolve_authenticated_participation(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
invitation_id: str,
gateway: PollResponseGatewayRef,
respondent_id: str,
) -> PollParticipationContextRef:
"""Resolve one governed invitation without retaining its public token."""
...
def submit_authenticated_response(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
invitation_id: str,
gateway: PollResponseGatewayRef,
respondent_id: str,
command: PollGovernedResponseCommand,
) -> PollGovernedResponseRef:
"""Submit atomically for the exact authenticated invitation identity."""
...
def add_option(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
command: PollOptionRequest,
) -> PollOptionMutationRef:
...
def remove_option(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
option_id: str,
) -> PollOptionMutationRef:
...
def revoke_invitation(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
invitation_id: str,
) -> PollInvitationRevocationRef:
...
def update_invitation_expiry(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
invitation_id: str,
gateway: PollResponseGatewayRef,
expires_at: datetime | None,
) -> PollInvitationExpiryRef:
"""Expire or extend a non-revoked governed invitation in place."""
...
def poll_participation_gateway_provider(
registry: object | None,
) -> PollParticipationGatewayProvider | None:
"""Resolve the governed Poll gateway without importing its implementation."""
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_POLL_PARTICIPATION_GATEWAY):
return None
capability = registry.capability(CAPABILITY_POLL_PARTICIPATION_GATEWAY)
return capability if isinstance(capability, PollParticipationGatewayProvider) else None
__all__ = [
"ANONYMOUS_PASSWORD_REQUIREMENT",
"CAPABILITY_POLL_PARTICIPATION_GATEWAY",
"PARTICIPATION_POLICY_VERSION",
"PollGovernedInvitationCommand",
"PollGovernedResponseCommand",
"PollGovernedResponseRef",
"PollInvitationExpiryRef",
"PollInvitationRevocationRef",
"PollOptionMutationRef",
"PollParticipationContextRef",
"PollParticipationGatewayProvider",
"PollParticipationPolicy",
"PollResponseGatewayRef",
"participation_token_fingerprint",
"poll_participation_gateway_provider",
]
+575
View File
@@ -0,0 +1,575 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.events import EventClassification
POSTBOX_MODULE_ID = "postbox"
CAPABILITY_POSTBOX_DIRECTORY = "postbox.directory"
CAPABILITY_POSTBOX_ACCESS = "postbox.access"
CAPABILITY_POSTBOX_MESSAGES = "postbox.messages"
CAPABILITY_POSTBOX_DELIVERY = "postbox.delivery"
CAPABILITY_POSTBOX_EVIDENCE = "postbox.evidence"
CAPABILITY_POSTBOX_ROUTING = "postbox.routing"
PostboxAction = Literal[
"discover",
"read",
"send",
"reply",
"acknowledge",
"administer",
]
PostboxMessageListState = Literal["all", "unread", "read", "acknowledged"]
PostboxMessageAvailability = Literal["available", "withdrawn", "expired"]
PostboxBindingStatus = Literal[
"active",
"missing",
"not_effective",
"unit_missing",
"unit_inactive",
"unit_tenant_mismatch",
"function_missing",
"function_inactive",
"function_tenant_mismatch",
"function_reassigned",
"directory_unavailable",
]
POSTBOX_CLASSIFICATIONS: tuple[EventClassification, ...] = (
"public",
"internal",
"confidential",
"restricted",
)
_POSTBOX_CLASSIFICATION_RANK = {
value: rank for rank, value in enumerate(POSTBOX_CLASSIFICATIONS)
}
def normalize_postbox_classification(
value: str,
) -> EventClassification | None:
candidate = value.strip().casefold()
if candidate not in _POSTBOX_CLASSIFICATION_RANK:
return None
return candidate # type: ignore[return-value]
def postbox_classification_allows(
ceiling: str,
content: str,
) -> bool:
normalized_ceiling = normalize_postbox_classification(ceiling)
normalized_content = normalize_postbox_classification(content)
if normalized_ceiling is None or normalized_content is None:
return False
return (
_POSTBOX_CLASSIFICATION_RANK[normalized_content]
<= _POSTBOX_CLASSIFICATION_RANK[normalized_ceiling]
)
@dataclass(frozen=True, slots=True)
class PostboxActorRef:
account_id: str
identity_id: str | None = None
selected_assignment_id: str | None = None
acting_for_account_id: str | None = None
authorized_actions: frozenset[PostboxAction] = frozenset()
authorized_classifications: frozenset[EventClassification] = frozenset(
{"public", "internal"}
)
@dataclass(frozen=True, slots=True)
class PostboxTargetRef:
postbox_id: str | None = None
address_key: str | None = None
template_id: str | None = None
organization_unit_id: str | None = None
function_id: str | None = None
context_key: str | None = None
@dataclass(frozen=True, slots=True)
class PostboxDeliveryTemplateRef:
id: str
slug: str
name: str
description: str | None
published_revision_id: str
function_type_id: str | None
scope_kind: str
scope_id: str | None
classification: str
allow_vacant_delivery: bool
@dataclass(frozen=True, slots=True)
class PostboxOrganizationFunctionTargetRef:
id: str
slug: str
name: str
function_type_id: str | None = None
@dataclass(frozen=True, slots=True)
class PostboxOrganizationUnitTargetRef:
id: str
slug: str
name: str
unit_type_id: str | None = None
parent_id: str | None = None
functions: tuple[PostboxOrganizationFunctionTargetRef, ...] = ()
@dataclass(frozen=True, slots=True)
class PostboxDeliveryCatalogRef:
postboxes: tuple["PostboxDirectoryEntryRef", ...] = ()
templates: tuple[PostboxDeliveryTemplateRef, ...] = ()
organization_units: tuple[PostboxOrganizationUnitTargetRef, ...] = ()
@dataclass(frozen=True, slots=True)
class PostboxAccessDecisionRef:
allowed: bool
action: PostboxAction
postbox_id: str
reason_code: str
explanation: str
organization_unit_id: str | None = None
function_id: str | None = None
assignment_ids: tuple[str, ...] = ()
assignment_sources: tuple[str, ...] = ()
selected_assignment_id: str | None = None
holder_count: int = 0
vacant: bool = True
classification: str = "internal"
classification_allowed: bool = True
binding_status: PostboxBindingStatus = "active"
@dataclass(frozen=True, slots=True)
class PostboxDirectoryEntryRef:
id: str
tenant_id: str
address: str
address_key: str
name: str
status: str
classification: str
organization_unit_id: str | None = None
organization_unit_name: str | None = None
function_id: str | None = None
function_name: str | None = None
context_key: str | None = None
template_revision_id: str | None = None
holder_count: int = 0
vacant: bool = True
access: PostboxAccessDecisionRef | None = None
resource_revision: int = 1
etag: str | None = None
@dataclass(frozen=True, slots=True)
class PostboxParticipantRef:
kind: str
reference_type: str
reference_id: str | None = None
label: str | None = None
address: str | None = None
@dataclass(frozen=True, slots=True)
class PostboxAttachmentRef:
reference_type: str
reference_id: str
name: str | None = None
media_type: str | None = None
size_bytes: int | None = None
digest: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxWrappedKeyRef:
"""Opaque envelope-key record; key material remains crypto-provider owned."""
recipient_type: str
recipient_id: str
key_epoch: int
wrapped_key_ref: str
algorithm: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxExternalRecipientTokenRef:
"""External grant state without the bearer secret itself."""
token_id: str
state: str
expires_at: datetime | None = None
one_time: bool = False
key_fetched_at: datetime | None = None
revoked_at: datetime | None = None
assurance_profile: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxMessageRef:
id: str
tenant_id: str
postbox_id: str
subject: str
body_text: str | None
status: str
availability: PostboxMessageAvailability
classification: str
sender_label: str | None
delivered_at: datetime
read_at: datetime | None = None
acknowledged_at: datetime | None = None
expires_at: datetime | None = None
withdrawn_at: datetime | None = None
producer_module: str | None = None
producer_resource_type: str | None = None
producer_resource_id: str | None = None
in_reply_to_message_id: str | None = None
replaces_message_id: str | None = None
encryption_profile: str = "plaintext_v1"
key_epoch: int = 1
ciphertext_ref: str | None = None
signed_manifest_ref: str | None = None
wrapped_keys: tuple[PostboxWrappedKeyRef, ...] = ()
external_recipient_tokens: tuple[PostboxExternalRecipientTokenRef, ...] = ()
participants: tuple[PostboxParticipantRef, ...] = ()
attachments: tuple[PostboxAttachmentRef, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxMessageAuthoringRequest:
idempotency_key: str
subject: str
body_text: str | None = None
classification: str = "internal"
participants: tuple[PostboxParticipantRef, ...] = ()
attachments: tuple[PostboxAttachmentRef, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxDeliveryReceiptSummaryRef:
delivery_id: str
message_id: str
postbox_id: str
delivery_status: str
accepted_at: datetime
current_holder_count: int = 0
currently_readable: bool = False
message_count: int = 1
routed_message_count: int = 0
readable_message_count: int = 0
read_receipt_count: int = 0
acknowledged_receipt_count: int = 0
withdrawn_message_count: int = 0
expired_message_count: int = 0
first_read_at: datetime | None = None
last_read_at: datetime | None = None
first_acknowledged_at: datetime | None = None
last_acknowledged_at: datetime | None = None
route_status_counts: Mapping[str, int] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxDeliveryRequest:
tenant_id: str
target: PostboxTargetRef
producer_module: str
producer_resource_type: str
producer_resource_id: str | None
idempotency_key: str
subject: str
body_text: str | None = None
sender_label: str | None = None
classification: str = "internal"
participants: tuple[PostboxParticipantRef, ...] = ()
attachments: tuple[PostboxAttachmentRef, ...] = ()
expires_at: datetime | None = None
ciphertext_ref: str | None = None
signed_manifest_ref: str | None = None
wrapped_keys: tuple[PostboxWrappedKeyRef, ...] = ()
external_recipient_tokens: tuple[PostboxExternalRecipientTokenRef, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxDeliveryResult:
delivery_id: str
postbox_id: str
message_id: str
address: str
status: str
vacant: bool
holder_count: int
duplicate: bool = False
evidence: Mapping[str, object] = field(default_factory=dict)
class PostboxDeliveryRejected(RuntimeError):
"""A delivery was rejected before the provider accepted any effect."""
def __init__(
self,
code: str,
message: str,
*,
temporary: bool = False,
) -> None:
super().__init__(message)
self.code = code
self.temporary = temporary
class PostboxDeliveryOutcomeUnknown(RuntimeError):
"""The provider may have accepted an effect and must not be bypassed."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
@runtime_checkable
class PostboxDirectoryProvider(Protocol):
def list_visible_postboxes(
self,
session: object,
*,
tenant_id: str,
actor: PostboxActorRef,
) -> Sequence[PostboxDirectoryEntryRef]:
...
def resolve_postbox(
self,
session: object,
*,
tenant_id: str,
target: PostboxTargetRef,
materialize: bool = False,
) -> PostboxDirectoryEntryRef | None:
...
def delivery_catalog(
self,
session: object,
*,
tenant_id: str,
) -> PostboxDeliveryCatalogRef:
...
@runtime_checkable
class PostboxAccessProvider(Protocol):
def explain_access(
self,
session: object,
*,
tenant_id: str,
postbox_id: str,
actor: PostboxActorRef,
action: PostboxAction,
) -> PostboxAccessDecisionRef:
...
@runtime_checkable
class PostboxMessagesProvider(Protocol):
def list_messages(
self,
session: object,
*,
tenant_id: str,
postbox_ids: Sequence[str],
actor: PostboxActorRef,
limit: int = 100,
offset: int = 0,
query: str | None = None,
state: PostboxMessageListState = "all",
) -> Sequence[PostboxMessageRef]:
...
def get_message(
self,
session: object,
*,
tenant_id: str,
message_id: str,
actor: PostboxActorRef,
) -> PostboxMessageRef | None:
...
def mark_message(
self,
session: object,
*,
tenant_id: str,
message_id: str,
actor: PostboxActorRef,
state: Literal["read", "acknowledged"],
) -> PostboxMessageRef:
...
def create_message(
self,
session: object,
*,
tenant_id: str,
postbox_id: str,
actor: PostboxActorRef,
request: PostboxMessageAuthoringRequest,
) -> PostboxMessageRef:
...
def reply_to_message(
self,
session: object,
*,
tenant_id: str,
message_id: str,
actor: PostboxActorRef,
request: PostboxMessageAuthoringRequest,
) -> PostboxMessageRef:
...
@runtime_checkable
class PostboxDeliveryProvider(Protocol):
def deliver(
self,
session: object,
request: PostboxDeliveryRequest,
) -> PostboxDeliveryResult:
...
@runtime_checkable
class PostboxEvidenceProvider(Protocol):
def link_evidence(
self,
session: object,
*,
tenant_id: str,
message_id: str,
attachment: PostboxAttachmentRef,
) -> PostboxMessageRef:
...
def delivery_receipt_summaries(
self,
session: object,
*,
tenant_id: str,
producer_module: str,
delivery_ids: Sequence[str],
) -> Mapping[str, PostboxDeliveryReceiptSummaryRef]:
...
@runtime_checkable
class PostboxRoutingProvider(Protocol):
def dispatch_due_routes(
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 50,
) -> Mapping[str, object]:
...
def _postbox_provider(
registry: object | None,
*,
capability_name: str,
provider_type: type,
) -> object | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(capability_name):
return None
capability = registry.capability(capability_name)
return capability if isinstance(capability, provider_type) else None
def postbox_directory_provider(
registry: object | None,
) -> PostboxDirectoryProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_DIRECTORY,
provider_type=PostboxDirectoryProvider,
)
return provider if isinstance(provider, PostboxDirectoryProvider) else None
def postbox_access_provider(
registry: object | None,
) -> PostboxAccessProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_ACCESS,
provider_type=PostboxAccessProvider,
)
return provider if isinstance(provider, PostboxAccessProvider) else None
def postbox_messages_provider(
registry: object | None,
) -> PostboxMessagesProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_MESSAGES,
provider_type=PostboxMessagesProvider,
)
return provider if isinstance(provider, PostboxMessagesProvider) else None
def postbox_delivery_provider(
registry: object | None,
) -> PostboxDeliveryProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_DELIVERY,
provider_type=PostboxDeliveryProvider,
)
return provider if isinstance(provider, PostboxDeliveryProvider) else None
def postbox_evidence_provider(
registry: object | None,
) -> PostboxEvidenceProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_EVIDENCE,
provider_type=PostboxEvidenceProvider,
)
return provider if isinstance(provider, PostboxEvidenceProvider) else None
def postbox_routing_provider(
registry: object | None,
) -> PostboxRoutingProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_ROUTING,
provider_type=PostboxRoutingProvider,
)
return provider if isinstance(provider, PostboxRoutingProvider) else None
+111
View File
@@ -0,0 +1,111 @@
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from govoplan_core.core.change_sequence import (
ChangeSequenceEntry,
retained_sequence_floor,
record_change,
)
AUTH_PRINCIPAL_REVISION_COLLECTION = "core.auth-principal-revisions"
@dataclass(frozen=True, slots=True)
class AuthPrincipalRevision:
system: int
tenant: int
def auth_principal_revision(
session: Session,
*,
tenant_id: str | None,
) -> AuthPrincipalRevision:
"""Return the durable global and tenant authorization revision.
The sequence rows make invalidation visible across processes. Retention
floors keep revisions monotonic if old change rows are pruned.
"""
rows = (
session.query(
ChangeSequenceEntry.tenant_id,
func.max(ChangeSequenceEntry.id),
)
.filter(
ChangeSequenceEntry.module_id == "core",
ChangeSequenceEntry.collection == AUTH_PRINCIPAL_REVISION_COLLECTION,
or_(
ChangeSequenceEntry.tenant_id.is_(None),
ChangeSequenceEntry.tenant_id == tenant_id,
),
)
.group_by(ChangeSequenceEntry.tenant_id)
.all()
)
revisions = {row_tenant_id: int(sequence_id or 0) for row_tenant_id, sequence_id in rows}
system = max(
revisions.get(None, 0),
retained_sequence_floor(
session,
tenant_id=None,
module_id="core",
collections=(AUTH_PRINCIPAL_REVISION_COLLECTION,),
),
)
tenant = 0
if tenant_id is not None:
tenant = max(
revisions.get(tenant_id, 0),
retained_sequence_floor(
session,
tenant_id=tenant_id,
module_id="core",
collections=(AUTH_PRINCIPAL_REVISION_COLLECTION,),
),
)
return AuthPrincipalRevision(system=system, tenant=tenant)
def invalidate_auth_principals(
session: Session,
*,
tenant_id: str | None,
source_module: str,
resource_type: str,
resource_id: str,
actor_type: str | None = None,
actor_id: str | None = None,
reason: str | None = None,
) -> ChangeSequenceEntry:
"""Advance the authorization revision in the caller's transaction."""
return record_change(
session,
tenant_id=tenant_id,
module_id="core",
collection=AUTH_PRINCIPAL_REVISION_COLLECTION,
resource_type="auth_principal_revision",
resource_id=tenant_id or "system",
operation="invalidated",
actor_type=actor_type,
actor_id=actor_id,
payload={
"source_module": source_module,
"resource_type": resource_type,
"resource_id": resource_id,
**({"reason": reason} if reason else {}),
},
)
__all__ = [
"AUTH_PRINCIPAL_REVISION_COLLECTION",
"AuthPrincipalRevision",
"auth_principal_revision",
"invalidate_auth_principals",
]
File diff suppressed because it is too large Load Diff
+669
View File
@@ -0,0 +1,669 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import StrEnum
import hashlib
import json
from typing import Any
from uuid import uuid4
from sqlalchemy import (
BigInteger,
DateTime,
ForeignKey,
Index,
Integer,
JSON,
String,
Text,
UniqueConstraint,
select,
)
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Mapped, Session, mapped_column
from govoplan_core.core.runtime_coordination import LeaseClaim, assert_lease_fence
from govoplan_core.db.base import Base, TimestampMixin, utcnow
from govoplan_core.security.redaction import contains_plain_secret
class RecoveryMode(StrEnum):
ATOMIC = "atomic"
COMPENSATION = "compensation"
SNAPSHOT_RESTORE = "snapshot_restore"
FORWARD_RECOVERY = "forward_recovery"
IRREVERSIBLE = "irreversible"
class RecoveryStatus(StrEnum):
PLANNED = "planned"
PREPARED = "prepared"
RUNNING = "running"
SUCCEEDED = "succeeded"
FAILED = "failed"
OUTCOME_UNKNOWN = "outcome_unknown"
RECOVERY_REQUIRED = "recovery_required"
RECOVERING = "recovering"
RECOVERED = "recovered"
MANUAL_INTERVENTION = "manual_intervention"
TERMINAL_RECOVERY_STATUSES = frozenset(
{
RecoveryStatus.SUCCEEDED.value,
RecoveryStatus.FAILED.value,
RecoveryStatus.RECOVERED.value,
RecoveryStatus.MANUAL_INTERVENTION.value,
}
)
_TRANSITIONS: dict[str, frozenset[str]] = {
RecoveryStatus.PLANNED.value: frozenset(
{RecoveryStatus.PREPARED.value, RecoveryStatus.FAILED.value}
),
RecoveryStatus.PREPARED.value: frozenset(
{RecoveryStatus.RUNNING.value, RecoveryStatus.FAILED.value}
),
RecoveryStatus.RUNNING.value: frozenset(
{
RecoveryStatus.SUCCEEDED.value,
RecoveryStatus.FAILED.value,
RecoveryStatus.OUTCOME_UNKNOWN.value,
RecoveryStatus.RECOVERY_REQUIRED.value,
}
),
RecoveryStatus.OUTCOME_UNKNOWN.value: frozenset(
{
RecoveryStatus.SUCCEEDED.value,
RecoveryStatus.RECOVERY_REQUIRED.value,
RecoveryStatus.MANUAL_INTERVENTION.value,
}
),
RecoveryStatus.RECOVERY_REQUIRED.value: frozenset(
{
RecoveryStatus.RECOVERING.value,
RecoveryStatus.MANUAL_INTERVENTION.value,
}
),
RecoveryStatus.RECOVERING.value: frozenset(
{
RecoveryStatus.RECOVERED.value,
RecoveryStatus.MANUAL_INTERVENTION.value,
}
),
}
class RecoveryGuaranteeError(ValueError):
pass
class RecoveryIdempotencyConflict(RecoveryGuaranteeError):
pass
class RecoveryOperation(Base, TimestampMixin):
__tablename__ = "core_recovery_operations"
__table_args__ = (
UniqueConstraint(
"installation_id",
"module_id",
"idempotency_key",
name="uq_core_recovery_operation_idempotency",
),
Index(
"ix_core_recovery_operations_status_updated",
"installation_id",
"status",
"updated_at",
),
Index(
"ix_core_recovery_operations_resource",
"module_id",
"resource_type",
"resource_id",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid4()),
)
installation_id: Mapped[str] = mapped_column(
String(100), nullable=False, index=True
)
module_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
operation_type: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
resource_type: Mapped[str | None] = mapped_column(String(100), index=True)
resource_id: Mapped[str | None] = mapped_column(String(255), index=True)
mode: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
status: Mapped[str] = mapped_column(
String(40),
default=RecoveryStatus.PLANNED.value,
nullable=False,
index=True,
)
idempotency_key: Mapped[str] = mapped_column(String(200), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
plan: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
backup_reference: Mapped[str | None] = mapped_column(String(1000))
approval_reference: Mapped[str | None] = mapped_column(String(1000))
lease_resource_key: Mapped[str | None] = mapped_column(String(255))
holder_node_id: Mapped[str | None] = mapped_column(String(200))
holder_incarnation: Mapped[str | None] = mapped_column(String(36))
fencing_token: Mapped[int | None] = mapped_column(
BigInteger().with_variant(Integer, "sqlite")
)
checkpoint_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
evidence_head_sha256: Mapped[str | None] = mapped_column(String(64))
failure_summary: Mapped[str | None] = mapped_column(Text)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
recovery_started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True)
)
recovered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
metadata_: Mapped[dict[str, Any]] = mapped_column(
"metadata", JSON, default=dict, nullable=False
)
class RecoveryCheckpoint(Base):
__tablename__ = "core_recovery_checkpoints"
__table_args__ = (
UniqueConstraint(
"operation_id",
"sequence",
name="uq_core_recovery_checkpoint_sequence",
),
Index(
"ix_core_recovery_checkpoints_operation_created",
"operation_id",
"created_at",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid4()),
)
operation_id: Mapped[str] = mapped_column(
ForeignKey("core_recovery_operations.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
kind: Mapped[str] = mapped_column(String(80), nullable=False)
summary: Mapped[str] = mapped_column(Text, nullable=False)
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
previous_sha256: Mapped[str | None] = mapped_column(String(64))
checkpoint_sha256: Mapped[str] = mapped_column(
String(64), nullable=False, index=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, nullable=False
)
@dataclass(frozen=True, slots=True)
class RecoveryPlan:
mode: RecoveryMode
preconditions: tuple[str, ...] = ()
compensation_steps: tuple[str, ...] = ()
forward_recovery_steps: tuple[str, ...] = ()
verification_steps: tuple[str, ...] = ()
backup_reference: str | None = None
approval_reference: str | None = None
def validate(self) -> None:
if not self.verification_steps:
raise RecoveryGuaranteeError(
"Recovery plans require at least one verification step"
)
if self.mode == RecoveryMode.COMPENSATION and not self.compensation_steps:
raise RecoveryGuaranteeError(
"Compensation recovery requires explicit compensation steps"
)
if self.mode == RecoveryMode.SNAPSHOT_RESTORE and not self.backup_reference:
raise RecoveryGuaranteeError(
"Snapshot restore requires a verified backup reference"
)
if (
self.mode == RecoveryMode.FORWARD_RECOVERY
and not self.forward_recovery_steps
):
raise RecoveryGuaranteeError(
"Forward recovery requires explicit forward-recovery steps"
)
if self.mode == RecoveryMode.IRREVERSIBLE and not self.approval_reference:
raise RecoveryGuaranteeError(
"Irreversible operations require an approval reference"
)
def as_dict(self) -> dict[str, Any]:
return {
"mode": self.mode.value,
"preconditions": list(self.preconditions),
"compensation_steps": list(self.compensation_steps),
"forward_recovery_steps": list(self.forward_recovery_steps),
"verification_steps": list(self.verification_steps),
"backup_reference": self.backup_reference,
"approval_reference": self.approval_reference,
}
def plan_recovery_operation(
session: Session,
*,
installation_id: str,
module_id: str,
operation_type: str,
idempotency_key: str,
request: dict[str, Any],
recovery_plan: RecoveryPlan,
resource_type: str | None = None,
resource_id: str | None = None,
lease_claim: LeaseClaim | None = None,
metadata: dict[str, Any] | None = None,
) -> RecoveryOperation:
recovery_plan.validate()
request_sha256 = _canonical_sha256(request)
existing = session.execute(
select(RecoveryOperation).where(
RecoveryOperation.installation_id == installation_id,
RecoveryOperation.module_id == module_id,
RecoveryOperation.idempotency_key == idempotency_key,
)
).scalar_one_or_none()
if existing is not None:
if existing.request_sha256 != request_sha256:
raise RecoveryIdempotencyConflict(
"Recovery operation idempotency key was reused for another request"
)
return existing
if contains_plain_secret(metadata or {}):
raise RecoveryGuaranteeError(
"Recovery metadata must contain secret references, not plaintext secrets"
)
if lease_claim is not None:
if lease_claim.installation_id != installation_id:
raise RecoveryGuaranteeError(
"Recovery operation and lease belong to different installations"
)
assert_lease_fence(session, lease_claim)
operation = RecoveryOperation(
installation_id=installation_id,
module_id=module_id,
operation_type=operation_type,
resource_type=resource_type,
resource_id=resource_id,
mode=recovery_plan.mode.value,
status=RecoveryStatus.PLANNED.value,
idempotency_key=idempotency_key,
request_sha256=request_sha256,
plan=recovery_plan.as_dict(),
backup_reference=recovery_plan.backup_reference,
approval_reference=recovery_plan.approval_reference,
lease_resource_key=lease_claim.resource_key if lease_claim else None,
holder_node_id=lease_claim.holder_node_id if lease_claim else None,
holder_incarnation=lease_claim.holder_incarnation if lease_claim else None,
fencing_token=lease_claim.fencing_token if lease_claim else None,
metadata_=dict(metadata or {}),
)
try:
with session.begin_nested():
session.add(operation)
session.flush()
except IntegrityError:
existing = session.execute(
select(RecoveryOperation).where(
RecoveryOperation.installation_id == installation_id,
RecoveryOperation.module_id == module_id,
RecoveryOperation.idempotency_key == idempotency_key,
)
).scalar_one_or_none()
if existing is None:
raise
if existing.request_sha256 != request_sha256:
raise RecoveryIdempotencyConflict(
"Recovery operation idempotency key was reused for another request"
)
return existing
record_recovery_checkpoint(
session,
operation,
kind="plan",
summary="Recovery contract recorded before side effects",
evidence={"request_sha256": request_sha256, "plan": recovery_plan.as_dict()},
lease_claim=lease_claim,
)
return operation
def prepare_recovery_operation(
session: Session,
operation: RecoveryOperation,
*,
evidence: dict[str, Any],
lease_claim: LeaseClaim | None = None,
) -> RecoveryOperation:
_verify_operation_fence(session, operation, lease_claim)
if not evidence:
raise RecoveryGuaranteeError(
"Recovery preparation requires durable precondition evidence"
)
return transition_recovery_operation(
session,
operation,
status=RecoveryStatus.PREPARED,
kind="prepared",
summary="Preconditions and recovery material verified",
evidence=evidence,
lease_claim=lease_claim,
)
def start_recovery_operation(
session: Session,
operation: RecoveryOperation,
*,
evidence: dict[str, Any] | None = None,
lease_claim: LeaseClaim | None = None,
now: datetime | None = None,
) -> RecoveryOperation:
_verify_operation_fence(session, operation, lease_claim)
operation.started_at = _as_utc(now or utcnow())
return transition_recovery_operation(
session,
operation,
status=RecoveryStatus.RUNNING,
kind="started",
summary="Guarded operation started",
evidence=evidence or {},
lease_claim=lease_claim,
)
def transition_recovery_operation(
session: Session,
operation: RecoveryOperation,
*,
status: RecoveryStatus,
kind: str,
summary: str,
evidence: dict[str, Any] | None = None,
failure_summary: str | None = None,
lease_claim: LeaseClaim | None = None,
now: datetime | None = None,
) -> RecoveryOperation:
locked = session.execute(
select(RecoveryOperation)
.where(RecoveryOperation.id == operation.id)
.with_for_update()
).scalar_one()
_verify_operation_fence(session, locked, lease_claim)
allowed = _TRANSITIONS.get(locked.status, frozenset())
if status.value not in allowed:
raise RecoveryGuaranteeError(
f"Recovery transition {locked.status!r} -> {status.value!r} is not allowed"
)
_validate_mode_transition(locked, status)
_validate_transition_evidence(
status=status,
evidence=evidence or {},
failure_summary=failure_summary,
)
observed_at = _as_utc(now or utcnow())
locked.status = status.value
locked.revision = int(locked.revision or 0) + 1
if failure_summary is not None:
locked.failure_summary = failure_summary
if status == RecoveryStatus.SUCCEEDED:
locked.completed_at = observed_at
elif status == RecoveryStatus.RECOVERING:
locked.recovery_started_at = observed_at
elif status == RecoveryStatus.RECOVERED:
locked.recovered_at = observed_at
locked.completed_at = observed_at
elif status in {RecoveryStatus.FAILED, RecoveryStatus.MANUAL_INTERVENTION}:
locked.completed_at = observed_at
session.add(locked)
record_recovery_checkpoint(
session,
locked,
kind=kind,
summary=summary,
evidence=evidence or {},
lease_claim=lease_claim,
now=observed_at,
)
session.flush()
return locked
def record_recovery_checkpoint(
session: Session,
operation: RecoveryOperation,
*,
kind: str,
summary: str,
evidence: dict[str, Any],
lease_claim: LeaseClaim | None = None,
now: datetime | None = None,
) -> RecoveryCheckpoint:
if contains_plain_secret(evidence):
raise RecoveryGuaranteeError(
"Recovery evidence must contain secret references, not plaintext secrets"
)
locked = session.execute(
select(RecoveryOperation)
.where(RecoveryOperation.id == operation.id)
.with_for_update()
).scalar_one()
_verify_operation_fence(session, locked, lease_claim)
observed_at = _as_utc(now or utcnow())
sequence = int(locked.checkpoint_count or 0) + 1
payload = {
"operation_id": locked.id,
"sequence": sequence,
"status": locked.status,
"kind": kind,
"summary": summary,
"evidence": evidence,
"previous_sha256": locked.evidence_head_sha256,
"created_at": observed_at.isoformat(),
}
checkpoint_hash = _canonical_sha256(payload)
checkpoint = RecoveryCheckpoint(
operation_id=locked.id,
sequence=sequence,
status=locked.status,
kind=kind,
summary=summary,
evidence=dict(evidence),
previous_sha256=locked.evidence_head_sha256,
checkpoint_sha256=checkpoint_hash,
created_at=observed_at,
)
locked.checkpoint_count = sequence
locked.evidence_head_sha256 = checkpoint_hash
session.add(locked)
session.add(checkpoint)
session.flush()
return checkpoint
def verify_recovery_evidence_chain(
session: Session,
operation_id: str,
) -> bool:
operation = session.get(RecoveryOperation, operation_id)
if operation is None:
raise RecoveryGuaranteeError("Recovery operation was not found")
checkpoints = (
session.execute(
select(RecoveryCheckpoint)
.where(RecoveryCheckpoint.operation_id == operation_id)
.order_by(RecoveryCheckpoint.sequence)
)
.scalars()
.all()
)
previous: str | None = None
for expected_sequence, checkpoint in enumerate(checkpoints, start=1):
if (
checkpoint.sequence != expected_sequence
or checkpoint.previous_sha256 != previous
):
return False
payload = {
"operation_id": checkpoint.operation_id,
"sequence": checkpoint.sequence,
"status": checkpoint.status,
"kind": checkpoint.kind,
"summary": checkpoint.summary,
"evidence": checkpoint.evidence,
"previous_sha256": checkpoint.previous_sha256,
"created_at": _as_utc(checkpoint.created_at).isoformat(),
}
if _canonical_sha256(payload) != checkpoint.checkpoint_sha256:
return False
previous = checkpoint.checkpoint_sha256
return (
len(checkpoints) == int(operation.checkpoint_count or 0)
and previous == operation.evidence_head_sha256
)
def operation_recovery_action(mode: RecoveryMode) -> RecoveryStatus:
if mode == RecoveryMode.ATOMIC:
return RecoveryStatus.FAILED
if mode in {
RecoveryMode.COMPENSATION,
RecoveryMode.SNAPSHOT_RESTORE,
RecoveryMode.FORWARD_RECOVERY,
}:
return RecoveryStatus.RECOVERY_REQUIRED
return RecoveryStatus.MANUAL_INTERVENTION
def _validate_mode_transition(
operation: RecoveryOperation,
status: RecoveryStatus,
) -> None:
mode = RecoveryMode(operation.mode)
if (
operation.status == RecoveryStatus.RUNNING.value
and status == RecoveryStatus.FAILED
and mode != RecoveryMode.ATOMIC
):
raise RecoveryGuaranteeError(
"A non-atomic operation cannot be marked failed after it starts; "
"record recovery_required, outcome_unknown, or manual_intervention"
)
if mode == RecoveryMode.ATOMIC and status in {
RecoveryStatus.RECOVERY_REQUIRED,
RecoveryStatus.RECOVERING,
RecoveryStatus.RECOVERED,
}:
raise RecoveryGuaranteeError(
"Atomic operations must roll back in their transaction instead of entering recovery"
)
if mode == RecoveryMode.IRREVERSIBLE and status in {
RecoveryStatus.RECOVERY_REQUIRED,
RecoveryStatus.RECOVERING,
RecoveryStatus.RECOVERED,
}:
raise RecoveryGuaranteeError(
"Irreversible operations cannot claim automated recovery"
)
def _validate_transition_evidence(
*,
status: RecoveryStatus,
evidence: dict[str, Any],
failure_summary: str | None,
) -> None:
if status in {RecoveryStatus.SUCCEEDED, RecoveryStatus.RECOVERED}:
checks = evidence.get("checks")
if (
evidence.get("verified") is not True
or not isinstance(
checks,
(dict, list),
)
or not checks
):
raise RecoveryGuaranteeError(
"Successful recovery transitions require verified evidence and check results"
)
if status == RecoveryStatus.MANUAL_INTERVENTION and not failure_summary:
raise RecoveryGuaranteeError(
"Manual intervention requires an operator-facing failure summary"
)
def _verify_operation_fence(
session: Session,
operation: RecoveryOperation,
lease_claim: LeaseClaim | None,
) -> None:
if operation.lease_resource_key is None:
return
if lease_claim is None:
raise RecoveryGuaranteeError(
"This recovery operation requires its distributed lease fence"
)
if (
lease_claim.resource_key != operation.lease_resource_key
or lease_claim.holder_node_id != operation.holder_node_id
or lease_claim.holder_incarnation != operation.holder_incarnation
or lease_claim.fencing_token != operation.fencing_token
):
raise RecoveryGuaranteeError(
"Recovery operation lease does not match its recorded fence"
)
assert_lease_fence(session, lease_claim)
def _canonical_sha256(value: dict[str, Any]) -> str:
encoded = json.dumps(
value,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
default=str,
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
__all__ = [
"RecoveryCheckpoint",
"RecoveryGuaranteeError",
"RecoveryIdempotencyConflict",
"RecoveryMode",
"RecoveryOperation",
"RecoveryPlan",
"RecoveryStatus",
"TERMINAL_RECOVERY_STATUSES",
"operation_recovery_action",
"plan_recovery_operation",
"prepare_recovery_operation",
"record_recovery_checkpoint",
"start_recovery_operation",
"transition_recovery_operation",
"verify_recovery_evidence_chain",
]
+499
View File
@@ -0,0 +1,499 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.access import (
CAPABILITY_ACCESS_DIRECTORY,
AccessDirectory,
GroupRef,
UserRef,
)
CAPABILITY_ACCESS_REFERENCE_OPTIONS = "access.reference_options"
ReferenceAvailability = Literal["available", "inactive", "unavailable"]
@dataclass(frozen=True, slots=True)
class ReferenceOption:
"""A stable identifier paired with presentation-only directory metadata."""
value: str
label: str
description: str | None = None
kind: str | None = None
availability: ReferenceAvailability = "available"
disabled: bool = False
source_module: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
def to_dict(self) -> dict[str, object]:
return {
"value": self.value,
"label": self.label,
"description": self.description,
"kind": self.kind,
"availability": self.availability,
"disabled": self.disabled,
"source_module": self.source_module,
"provenance": dict(self.provenance),
}
@dataclass(frozen=True, slots=True)
class ReferenceSearchRequest:
kind: str
tenant_id: str | None
query: str = ""
selected_values: tuple[str, ...] = ()
limit: int = 50
cursor: str | None = None
context: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ReferenceSearchPage:
options: tuple[ReferenceOption, ...] = ()
next_cursor: str | None = None
has_more: bool = False
@runtime_checkable
class ReferenceOptionProvider(Protocol):
"""Optional module-neutral provider for typed reference selectors."""
def search_reference_options(
self,
session: object,
principal: object,
*,
request: ReferenceSearchRequest,
) -> ReferenceSearchPage:
...
def reference_option_provider(
registry: object | None,
capability_name: str,
) -> ReferenceOptionProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(capability_name)
):
return None
provider = registry.capability(capability_name)
return provider if isinstance(provider, ReferenceOptionProvider) else None
def access_scope_reference_options(
registry: object | None,
principal: object,
*,
scope_type: str,
query: str = "",
selected_values: Sequence[str] = (),
limit: int = 50,
administrative: bool = False,
session: object | None = None,
reference_kind: str | None = None,
) -> tuple[ReferenceOption, ...]:
"""Return canonical user-account or group references for definition scopes."""
return access_scope_reference_page(
registry,
principal,
scope_type=scope_type,
query=query,
selected_values=selected_values,
limit=limit,
administrative=administrative,
session=session,
reference_kind=reference_kind,
).options
def access_scope_reference_page(
registry: object | None,
principal: object,
*,
scope_type: str,
query: str = "",
selected_values: Sequence[str] = (),
limit: int = 50,
cursor: str | None = None,
administrative: bool = False,
session: object | None = None,
reference_kind: str | None = None,
) -> ReferenceSearchPage:
"""Search bounded Access references while retaining selected values."""
clean_type = str(scope_type or "").strip().casefold()
if clean_type not in {"user", "group"}:
raise ValueError("Scope reference type must be user or group.")
clean_kind = str(reference_kind or clean_type).strip().casefold()
if clean_kind not in (
{"user", "membership"} if clean_type == "user" else {"group"}
):
raise ValueError("Scope reference kind is incompatible with the scope type.")
normalized_limit = max(1, min(int(limit), 200))
selected = tuple(
dict.fromkeys(
str(value).strip() for value in selected_values if str(value).strip()
)
)[:200]
tenant_id = str(getattr(principal, "tenant_id", "") or "")
provider = reference_option_provider(
registry,
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
)
if provider is not None:
if not tenant_id:
return ReferenceSearchPage(
options=tuple(
_unavailable_option(value, kind=clean_kind)
for value in selected
)
)
page = provider.search_reference_options(
session,
principal,
request=ReferenceSearchRequest(
kind=clean_kind,
tenant_id=tenant_id,
query=str(query or "").strip().casefold(),
selected_values=selected,
limit=normalized_limit,
cursor=str(cursor).strip() if cursor else None,
context={"administrative": administrative},
),
)
return ReferenceSearchPage(
options=_bounded_page_options(
page.options,
selected_values=selected,
limit=normalized_limit,
kind=clean_kind,
),
next_cursor=page.next_cursor,
has_more=page.has_more,
)
directory = _access_directory(registry)
if directory is None:
return ReferenceSearchPage(
options=_fallback_scope_options(
principal,
scope_type=clean_type,
reference_kind=clean_kind,
query=query,
selected_values=selected,
limit=normalized_limit,
)
)
if not tenant_id:
return ReferenceSearchPage(
options=tuple(
_unavailable_option(value, kind=clean_kind) for value in selected
)
)
if clean_type == "user":
candidates = _user_options(
directory.users_for_tenant(tenant_id),
principal=principal,
administrative=administrative,
value_kind=clean_kind,
)
else:
candidates = _group_options(
directory.groups_for_tenant(tenant_id),
principal=principal,
administrative=administrative,
)
return ReferenceSearchPage(
options=_filter_and_retain_options(
candidates,
query=query,
selected_values=selected,
limit=normalized_limit,
kind=clean_kind,
)
)
def access_scope_reference_provider_available(registry: object | None) -> bool:
return (
reference_option_provider(
registry,
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
)
is not None
or _access_directory(registry) is not None
)
def validate_access_scope_reference(
registry: object | None,
*,
tenant_id: str | None,
scope_type: str,
scope_id: str | None,
preserve_existing: str | None = None,
) -> str | None:
"""Validate new directory-backed scope references while preserving history."""
clean_type = str(scope_type or "").strip().casefold()
clean_id = str(scope_id or "").strip() or None
if clean_type not in {"user", "group"}:
return clean_id
if not clean_id:
raise ValueError(f"{clean_type.capitalize()} definitions require a scope ID.")
if clean_id == preserve_existing:
return clean_id
directory = _access_directory(registry)
if directory is None:
return clean_id
if not tenant_id:
raise ValueError("Directory-backed scopes require an active tenant.")
if clean_type == "group":
group = directory.get_group(clean_id)
if group is None or group.tenant_id != tenant_id:
raise ValueError("The selected group is not available in the active tenant.")
if group.status != "active":
raise ValueError("Inactive groups cannot be selected for new definitions.")
return group.id
matching_user = next(
(
user
for user in directory.users_for_tenant(tenant_id)
if user.account_id == clean_id
),
None,
)
if matching_user is None:
raise ValueError("The selected user account is not available in the active tenant.")
if matching_user.status != "active":
raise ValueError("Inactive users cannot be selected for new definitions.")
account = directory.get_account(matching_user.account_id)
if account is not None and account.status != "active":
raise ValueError("Inactive user accounts cannot be selected for new definitions.")
return matching_user.account_id
def _access_directory(registry: object | None) -> AccessDirectory | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY)
):
return None
provider = registry.capability(CAPABILITY_ACCESS_DIRECTORY)
return provider if isinstance(provider, AccessDirectory) else None
def _user_options(
users: Sequence[UserRef],
*,
principal: object,
administrative: bool,
value_kind: str = "user",
) -> tuple[ReferenceOption, ...]:
principal_account_id = str(getattr(principal, "account_id", "") or "")
options: dict[str, ReferenceOption] = {}
for user in users:
if not administrative and user.account_id != principal_account_id:
continue
inactive = user.status != "active"
label = user.display_name or user.email or user.account_id
detail_parts = [part for part in (user.email, "Inactive" if inactive else None) if part]
value = user.id if value_kind == "membership" else user.account_id
options[value] = ReferenceOption(
value=value,
label=label,
description=" · ".join(detail_parts) or None,
kind=value_kind,
availability="inactive" if inactive else "available",
disabled=inactive,
source_module="access",
provenance={
"membership_id": user.id,
"tenant_id": user.tenant_id,
"account_id": user.account_id,
},
)
return tuple(options.values())
def _group_options(
groups: Sequence[GroupRef],
*,
principal: object,
administrative: bool,
) -> tuple[ReferenceOption, ...]:
permitted = {
str(value)
for value in getattr(principal, "group_ids", ())
if str(value)
}
return tuple(
ReferenceOption(
value=group.id,
label=group.name or group.id,
description="Inactive" if group.status != "active" else None,
kind="group",
availability=(
"inactive" if group.status != "active" else "available"
),
disabled=group.status != "active",
source_module="access",
provenance={"tenant_id": group.tenant_id, "group_id": group.id},
)
for group in groups
if administrative or group.id in permitted
)
def _filter_and_retain_options(
options: Sequence[ReferenceOption],
*,
query: str,
selected_values: Sequence[str],
limit: int,
kind: str,
) -> tuple[ReferenceOption, ...]:
by_value = {option.value: option for option in options}
needle = str(query or "").strip().casefold()
matches = [
option
for option in options
if not needle
or needle
in " ".join(
(
option.value,
option.label,
option.description or "",
)
).casefold()
][:limit]
returned = {option.value for option in matches}
for value in selected_values:
if value in returned:
continue
matches.append(by_value.get(value) or _unavailable_option(value, kind=kind))
returned.add(value)
return tuple(matches)
def _bounded_page_options(
options: Sequence[ReferenceOption],
*,
selected_values: Sequence[str],
limit: int,
kind: str,
) -> tuple[ReferenceOption, ...]:
by_value = {option.value: option for option in options}
selected = set(selected_values)
bounded = [
option for option in options if option.value not in selected
][:limit]
returned = {option.value for option in bounded}
for value in selected_values:
if value in returned:
continue
bounded.append(by_value.get(value) or _unavailable_option(value, kind=kind))
returned.add(value)
return tuple(bounded)
def _fallback_scope_options(
principal: object,
*,
scope_type: str,
reference_kind: str | None = None,
query: str,
selected_values: Sequence[str],
limit: int,
) -> tuple[ReferenceOption, ...]:
candidates: list[ReferenceOption] = []
if scope_type == "user":
kind = str(reference_kind or "user")
value = str(
(
getattr(principal, "membership_id", "")
if kind == "membership"
else getattr(principal, "account_id", "")
)
or ""
)
if value:
candidates.append(
ReferenceOption(
value=value,
label=(
str(getattr(principal, "display_name", "") or "")
or str(getattr(principal, "email", "") or "")
or value
),
description="Current user · Access directory unavailable",
kind=kind,
source_module="core",
provenance={"fallback": True},
)
)
else:
candidates.extend(
ReferenceOption(
value=str(group_id),
label=str(group_id),
description="Permitted group · Access directory unavailable",
kind="group",
source_module="core",
provenance={"fallback": True},
)
for group_id in getattr(principal, "group_ids", ())
if str(group_id)
)
return _filter_and_retain_options(
candidates,
query=query,
selected_values=selected_values,
limit=limit,
kind=scope_type,
)
def _unavailable_option(value: str, *, kind: str) -> ReferenceOption:
return ReferenceOption(
value=value,
label=f"Unavailable {kind}",
description=value,
kind=kind,
availability="unavailable",
disabled=True,
source_module=None,
provenance={"retained_reference": True},
)
__all__ = [
"CAPABILITY_ACCESS_REFERENCE_OPTIONS",
"ReferenceAvailability",
"ReferenceOption",
"ReferenceOptionProvider",
"ReferenceSearchPage",
"ReferenceSearchRequest",
"access_scope_reference_page",
"access_scope_reference_options",
"access_scope_reference_provider_available",
"reference_option_provider",
"validate_access_scope_reference",
]
+740 -3
View File
@@ -16,13 +16,42 @@ from govoplan_core.core.modules import (
ModuleManifest,
NavItem,
PermissionDefinition,
PublicFrontendRoute,
ResourceAclProvider,
RoleTemplate,
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
SUPPORTED_MANIFEST_CONTRACT_VERSION,
TenantSummaryBatchProvider,
TenantSummaryProvider,
user_workflow_scope_condition_issues,
)
from govoplan_core.core.ownership import (
OwnershipProviderRegistration,
ResourceOwnershipProvider,
)
from govoplan_core.core.provider_governance import (
ExternalProviderDeclaration,
ExternalProviderStateProviderRegistration,
ModuleArchitectureDeclaration,
module_architecture_issues,
)
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
from govoplan_core.core.search import (
RegisteredSearchProvider,
RegisteredSearchSourceProvider,
SearchProvider,
SearchSourceProvider,
)
from govoplan_core.core.views import (
ViewSurface,
module_view_surface_id,
navigation_view_surface_id,
route_view_surface_id,
validate_view_surface_id,
)
from govoplan_core.core.workflows import (
workflow_definition_contribution_hash,
)
_MODULE_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
_NPM_PACKAGE_RE = re.compile(r"^(?:@[a-z0-9][a-z0-9_.-]*/)?[a-z0-9][a-z0-9_.-]*$")
@@ -47,10 +76,18 @@ class PlatformRegistry:
def __init__(self) -> None:
self._manifests: dict[str, ModuleManifest] = {}
self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {}
self._tenant_summary_batch_providers: dict[str, TenantSummaryBatchProvider] = {}
self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = defaultdict(list)
self._ownership_providers: dict[str, OwnershipProviderRegistration] = {}
self._capability_factories: dict[str, CapabilityFactory] = {}
self._capabilities: dict[str, object] = {}
self._capability_context: ModuleContext | None = None
self._search_provider_registrations: list[RegisteredSearchProvider] = []
self._search_providers: dict[str, SearchProvider] = {}
self._search_source_registrations: list[
RegisteredSearchSourceProvider
] = []
self._search_sources: dict[str, SearchSourceProvider] = {}
def register(self, manifest: ModuleManifest) -> ModuleManifest:
if manifest.id in self._manifests:
@@ -58,11 +95,29 @@ class PlatformRegistry:
self._manifests[manifest.id] = manifest
for provider in manifest.tenant_summary_providers:
self.register_tenant_summary_provider(manifest.id, provider)
for provider in manifest.tenant_summary_batch_providers:
self.register_tenant_summary_batch_provider(manifest.id, provider)
for resource_type, providers in manifest.delete_veto_providers.items():
for provider in providers:
self.register_delete_veto(manifest.id, resource_type, provider)
for registration in manifest.ownership_providers:
self.register_ownership_provider(manifest.id, registration)
for name, factory in manifest.capability_factories.items():
self.register_capability_factory(manifest.id, name, factory)
for registration in manifest.search_providers:
self._search_provider_registrations.append(
RegisteredSearchProvider(
module_id=manifest.id,
registration=registration,
)
)
for registration in manifest.search_sources:
self._search_source_registrations.append(
RegisteredSearchSourceProvider(
module_id=manifest.id,
registration=registration,
)
)
return manifest
def replace(self, manifests: Iterable[ModuleManifest]) -> RegistrySnapshot:
@@ -75,12 +130,24 @@ class PlatformRegistry:
self._manifests = dict(replacement._manifests)
self._tenant_summary_providers = dict(replacement._tenant_summary_providers)
self._tenant_summary_batch_providers = dict(
replacement._tenant_summary_batch_providers
)
self._delete_veto_providers = defaultdict(list, {
resource_type: list(providers)
for resource_type, providers in replacement._delete_veto_providers.items()
})
self._ownership_providers = dict(replacement._ownership_providers)
self._capability_factories = dict(replacement._capability_factories)
self._search_provider_registrations = list(
replacement._search_provider_registrations
)
self._search_source_registrations = list(
replacement._search_source_registrations
)
self._capabilities.clear()
self._search_providers.clear()
self._search_sources.clear()
return snapshot
def get(self, module_id: str) -> ModuleManifest | None:
@@ -102,6 +169,33 @@ class PlatformRegistry:
def manifests(self) -> tuple[ModuleManifest, ...]:
return tuple(self._topologically_sorted())
def module_architectures(
self,
) -> tuple[tuple[str, ModuleArchitectureDeclaration], ...]:
return tuple(
(manifest.id, manifest.architecture)
for manifest in self.manifests()
if manifest.architecture is not None
)
def external_provider_declarations(
self,
) -> tuple[ExternalProviderDeclaration, ...]:
return tuple(
declaration
for manifest in self.manifests()
for declaration in manifest.external_providers
)
def external_provider_state_providers(
self,
) -> tuple[ExternalProviderStateProviderRegistration, ...]:
return tuple(
registration
for manifest in self.manifests()
for registration in manifest.external_provider_state_providers
)
def permissions(self) -> tuple[PermissionDefinition, ...]:
return tuple(permission for manifest in self.manifests() for permission in manifest.permissions)
@@ -111,12 +205,59 @@ class PlatformRegistry:
def nav_items(self) -> tuple[NavItem, ...]:
return tuple(sorted((item for manifest in self.manifests() for item in manifest.nav_items), key=lambda item: item.order))
def view_surfaces(self) -> tuple[ViewSurface, ...]:
return tuple(
surface
for manifest in self.manifests()
for surface in manifest_view_surfaces(manifest)
)
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers)
def register_ownership_provider(
self,
module_id: str,
registration: OwnershipProviderRegistration,
) -> None:
resource_type = registration.resource_type.strip().lower()
if not resource_type:
raise RegistryError(
f"Ownership provider in {module_id} has no resource type"
)
if resource_type in self._ownership_providers:
raise RegistryError(
f"Duplicate ownership provider for resource type: {resource_type}"
)
if not isinstance(registration.provider, ResourceOwnershipProvider):
raise RegistryError(
f"Ownership provider for {resource_type} does not implement "
"ResourceOwnershipProvider"
)
self._ownership_providers[resource_type] = OwnershipProviderRegistration(
resource_type=resource_type,
provider=registration.provider,
module_id=module_id,
)
def ownership_provider(
self,
resource_type: str,
) -> ResourceOwnershipProvider | None:
registration = self.ownership_provider_registration(resource_type)
return registration.provider if registration else None
def ownership_provider_registration(
self,
resource_type: str,
) -> OwnershipProviderRegistration | None:
return self._ownership_providers.get(resource_type.strip().lower())
def configure_capability_context(self, context: ModuleContext) -> None:
self._capability_context = context
self._capabilities.clear()
self._search_providers.clear()
self._search_sources.clear()
def register_capability_factory(self, module_id: str, name: str, factory: CapabilityFactory) -> None:
if name in self._capability_factories:
@@ -126,6 +267,9 @@ class PlatformRegistry:
def has_capability(self, name: str) -> bool:
return name in self._capability_factories
def capability_names(self) -> tuple[str, ...]:
return tuple(sorted(self._capability_factories))
def capability(self, name: str) -> object | None:
if name in self._capabilities:
return self._capabilities[name]
@@ -144,12 +288,95 @@ class PlatformRegistry:
raise RegistryError(f"Required capability is not available: {name}")
return capability
def search_provider_registrations(
self,
) -> tuple[RegisteredSearchProvider, ...]:
return tuple(
sorted(
self._search_provider_registrations,
key=lambda item: (
item.registration.order,
item.module_id,
item.registration.id,
),
)
)
def search_providers(
self,
) -> tuple[tuple[RegisteredSearchProvider, SearchProvider], ...]:
if self._capability_context is None:
if self._search_provider_registrations:
raise RegistryError("Search provider context is not configured.")
return ()
providers: list[tuple[RegisteredSearchProvider, SearchProvider]] = []
for registered in self.search_provider_registrations():
key = f"{registered.module_id}:{registered.registration.id}"
provider = self._search_providers.get(key)
if provider is None:
provider = registered.registration.create(self._capability_context)
self._search_providers[key] = provider
providers.append((registered, provider))
return tuple(providers)
def search_source_registrations(
self,
) -> tuple[RegisteredSearchSourceProvider, ...]:
return tuple(
sorted(
self._search_source_registrations,
key=lambda item: (
item.registration.order,
item.module_id,
item.registration.id,
),
)
)
def search_sources(
self,
) -> tuple[
tuple[RegisteredSearchSourceProvider, SearchSourceProvider],
...,
]:
if self._capability_context is None:
if self._search_source_registrations:
raise RegistryError(
"Search source context is not configured."
)
return ()
providers: list[
tuple[RegisteredSearchSourceProvider, SearchSourceProvider]
] = []
for registered in self.search_source_registrations():
key = f"{registered.module_id}:{registered.registration.id}"
provider = self._search_sources.get(key)
if provider is None:
provider = registered.registration.create(
self._capability_context
)
self._search_sources[key] = provider
providers.append((registered, provider))
return tuple(providers)
def register_tenant_summary_provider(self, module_id: str, provider: TenantSummaryProvider) -> None:
self._tenant_summary_providers[module_id] = provider
def tenant_summary_providers(self) -> Mapping[str, TenantSummaryProvider]:
return dict(self._tenant_summary_providers)
def register_tenant_summary_batch_provider(
self,
module_id: str,
provider: TenantSummaryBatchProvider,
) -> None:
self._tenant_summary_batch_providers[module_id] = provider
def tenant_summary_batch_providers(
self,
) -> Mapping[str, TenantSummaryBatchProvider]:
return dict(self._tenant_summary_batch_providers)
def register_delete_veto(self, module_id: str, resource_type: str, provider: DeleteVetoProvider) -> None:
self._delete_veto_providers[resource_type].append(DeleteVetoProviderRegistration(
module_id=module_id,
@@ -194,6 +421,7 @@ class PlatformRegistry:
available_capabilities=available_capabilities,
)
permissions = _collect_manifest_permissions(ordered)
_validate_public_frontend_route_uniqueness(ordered)
_validate_interface_closure(ordered)
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
@@ -227,6 +455,53 @@ class PlatformRegistry:
return (self._manifests[module_id] for module_id in ordered)
def manifest_view_surfaces(manifest: ModuleManifest) -> tuple[ViewSurface, ...]:
frontend = manifest.frontend
if frontend is None:
return ()
root_id = module_view_surface_id(manifest.id)
surfaces = [
ViewSurface(
id=root_id,
module_id=manifest.id,
kind="module",
label=manifest.name,
order=min((item.order for item in frontend.nav_items), default=100),
)
]
surfaces.extend(
ViewSurface(
id=item.surface_id or navigation_view_surface_id(manifest.id, item.path),
module_id=manifest.id,
kind="navigation",
label=item.label,
parent_id=root_id,
order=item.order,
)
for item in frontend.nav_items
)
surfaces.extend(
ViewSurface(
id=route.surface_id or route_view_surface_id(manifest.id, route.path),
module_id=manifest.id,
kind="route",
label=route.component,
parent_id=root_id,
description=route.path,
order=route.order,
)
for route in (*frontend.routes, *frontend.settings_routes)
)
surfaces.extend(
replace(
surface,
parent_id=surface.parent_id or root_id,
)
for surface in frontend.view_surfaces
)
return tuple(surfaces)
def _normalize_delete_veto_result(
result: object,
*,
@@ -304,8 +579,12 @@ def _validate_manifest_permission(
permission: PermissionDefinition,
seen_permissions: Mapping[str, PermissionDefinition],
) -> None:
if permission.module_id != manifest.id:
raise RegistryError(f"Permission {permission.scope!r} has mismatched module id {permission.module_id!r}")
permission_namespace = manifest.permission_namespace or manifest.id
if permission.module_id != permission_namespace:
raise RegistryError(
f"Permission {permission.scope!r} has mismatched module id "
f"{permission.module_id!r}; expected {permission_namespace!r}"
)
if not _SCOPE_RE.match(permission.scope):
raise RegistryError(f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}")
expected_prefix = f"{permission.module_id}:{permission.resource}:"
@@ -320,9 +599,34 @@ def _validate_role_template_scopes(
*,
known_scopes: set[str],
) -> None:
seen_templates: dict[tuple[str, str], str] = {}
for manifest in manifests:
for template in manifest.role_templates:
template_key = (template.level, template.slug)
previous_module = seen_templates.get(template_key)
if previous_module is not None:
raise RegistryError(
f"Duplicate {template.level} role template slug {template.slug!r} "
f"in modules {previous_module!r} and {manifest.id!r}"
)
seen_templates[template_key] = manifest.id
if template.default_authenticated and template.level != "tenant":
raise RegistryError(
f"Default authenticated role template {template.slug!r} must be tenant-level"
)
if template.default_authenticated and not template.managed:
raise RegistryError(
f"Default authenticated role template {template.slug!r} must be managed"
)
for scope in template.permissions:
if template.default_authenticated and (
scope in {"*", "tenant:*", "system:*"}
or _WILDCARD_RE.match(scope)
):
raise RegistryError(
f"Default authenticated role template {template.slug!r} "
"must use explicit permissions, not wildcard scopes"
)
if _role_template_scope_known(scope, known_scopes):
continue
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
@@ -344,6 +648,278 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
_validate_manifest_frontend(manifest)
for item in manifest.nav_items:
_validate_nav_item(manifest.id, item)
for topic in manifest.documentation:
if not version_range_is_valid(
version_min=topic.version_min,
version_max_exclusive=topic.version_max_exclusive,
):
raise RegistryError(
f"Module {manifest.id!r} documentation topic {topic.id!r} "
"declares an empty version range"
)
for issue in user_workflow_scope_condition_issues(topic):
raise RegistryError(
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
)
_validate_documentation_extensions(manifest)
_validate_architecture_declarations(manifest)
_validate_workflow_definition_contributions(manifest)
def _validate_architecture_declarations(manifest: ModuleManifest) -> None:
architecture = manifest.architecture
if architecture is not None:
for issue in module_architecture_issues(
architecture,
has_migrations=manifest.migration_spec is not None,
):
raise RegistryError(
f"Module {manifest.id!r} architecture declaration: {issue}"
)
provider_ids: set[str] = set()
declared_capabilities = {
*manifest.required_capabilities,
*manifest.optional_capabilities,
*manifest.capability_factories,
}
declared_interfaces = {
*(item.name for item in manifest.provides_interfaces),
*(item.name for item in manifest.requires_interfaces),
}
operational_check_ids = {
item.check_id for item in manifest.operational_check_providers
}
documentation_topic_ids = {item.id for item in manifest.documentation}
ownership_resource_types = {
item.resource_type for item in manifest.ownership_providers
}
provider_authority_modes: set[str] = set()
for declaration in manifest.external_providers:
if declaration.module_id != manifest.id:
raise RegistryError(
f"Provider declaration {declaration.id!r} belongs to "
f"{declaration.module_id!r}, not module {manifest.id!r}"
)
if declaration.id in provider_ids:
raise RegistryError(
f"Module {manifest.id!r} declares duplicate external provider "
f"{declaration.id!r}"
)
provider_ids.add(declaration.id)
provider_authority_modes.update(declaration.authority_modes)
_validate_provider_references(
manifest.id,
declaration,
declared_capabilities=declared_capabilities,
declared_interfaces=declared_interfaces,
operational_check_ids=operational_check_ids,
documentation_topic_ids=documentation_topic_ids,
ownership_resource_types=ownership_resource_types,
)
state_provider_ids: set[str] = set()
for registration in manifest.external_provider_state_providers:
if registration.module_id != manifest.id:
raise RegistryError(
f"Provider state registration {registration.provider_id!r} belongs "
f"to {registration.module_id!r}, not module {manifest.id!r}"
)
if registration.provider_id not in provider_ids:
raise RegistryError(
f"Module {manifest.id!r} registers runtime state for undeclared "
f"provider {registration.provider_id!r}"
)
if registration.provider_id in state_provider_ids:
raise RegistryError(
f"Module {manifest.id!r} registers duplicate runtime state for "
f"provider {registration.provider_id!r}"
)
state_provider_ids.add(registration.provider_id)
missing_state_provider_ids = provider_ids - state_provider_ids
if missing_state_provider_ids:
raise RegistryError(
f"Module {manifest.id!r} external providers require sanitized runtime "
"state providers: " + ", ".join(sorted(missing_state_provider_ids))
)
if architecture is None:
if manifest.external_providers or manifest.external_provider_state_providers:
raise RegistryError(
f"Module {manifest.id!r} declares external providers without a "
"module architecture declaration"
)
return
missing_modes = provider_authority_modes - set(
architecture.supported_authority_modes
)
if missing_modes:
raise RegistryError(
f"Module {manifest.id!r} provider authority modes are not declared "
"by its architecture metadata: " + ", ".join(sorted(missing_modes))
)
unknown_target_providers = set(
architecture.target_tested_providers
) - provider_ids
if unknown_target_providers:
raise RegistryError(
f"Module {manifest.id!r} target-tested providers are not declared: "
+ ", ".join(sorted(unknown_target_providers))
)
def _validate_provider_references(
module_id: str,
declaration: ExternalProviderDeclaration,
*,
declared_capabilities: set[str],
declared_interfaces: set[str],
operational_check_ids: set[str],
documentation_topic_ids: set[str],
ownership_resource_types: set[str],
) -> None:
references = (
(
"capabilities",
set(declaration.capability_names),
declared_capabilities,
),
(
"interfaces",
set(declaration.interface_names),
declared_interfaces,
),
(
"operational checks",
set(declaration.operational_check_ids),
operational_check_ids,
),
(
"documentation topics",
set(declaration.documentation_topic_ids),
documentation_topic_ids,
),
(
"ownership resource types",
set(declaration.ownership_resource_types),
ownership_resource_types,
),
)
for label, requested, available in references:
missing = requested - available
if missing:
raise RegistryError(
f"Module {module_id!r} provider {declaration.id!r} references "
f"undeclared {label}: " + ", ".join(sorted(missing))
)
def _validate_workflow_definition_contributions(
manifest: ModuleManifest,
) -> None:
seen_keys: set[str] = set()
for contribution in manifest.workflow_definitions:
if contribution.origin_module_id != manifest.id:
raise RegistryError(
f"Workflow contribution {contribution.definition_key!r} in "
f"module {manifest.id!r} declares origin module "
f"{contribution.origin_module_id!r}"
)
if contribution.origin_module_version != manifest.version:
raise RegistryError(
f"Workflow contribution {contribution.definition_key!r} in "
f"module {manifest.id!r} declares origin version "
f"{contribution.origin_module_version!r}, expected "
f"{manifest.version!r}"
)
if not re.match(
r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,119}$",
contribution.definition_key,
):
raise RegistryError(
f"Workflow contribution key is invalid: "
f"{contribution.definition_key!r}"
)
if contribution.definition_key in seen_keys:
raise RegistryError(
f"Module {manifest.id!r} declares duplicate Workflow "
f"contribution {contribution.definition_key!r}"
)
seen_keys.add(contribution.definition_key)
if contribution.scope_type not in {"system", "tenant"}:
raise RegistryError(
f"Workflow contribution {contribution.definition_key!r} has "
f"unsupported scope {contribution.scope_type!r}"
)
expected_hash = workflow_definition_contribution_hash(contribution)
if (
contribution.content_hash is not None
and contribution.content_hash != expected_hash
):
raise RegistryError(
f"Workflow contribution {contribution.definition_key!r} has "
"a content hash that does not match its canonical content"
)
def _validate_documentation_extensions(manifest: ModuleManifest) -> None:
for capability, metadata in manifest.capability_documentation.items():
if capability not in manifest.capability_factories:
raise RegistryError(
f"Module {manifest.id!r} documents capability {capability!r} "
"but does not provide it"
)
if not metadata.label.strip():
raise RegistryError(
f"Module {manifest.id!r} capability {capability!r} "
"documentation must have a label"
)
if not metadata.summary.strip():
raise RegistryError(
f"Module {manifest.id!r} capability {capability!r} "
"documentation must have a summary"
)
if metadata.contract_version is not None and not metadata.contract_version.strip():
raise RegistryError(
f"Module {manifest.id!r} capability {capability!r} "
"documentation contract version must not be empty"
)
provider_keys: set[str] = set()
for registration in manifest.documentation_configuration_providers:
if not registration.keys:
raise RegistryError(
f"Module {manifest.id!r} documentation configuration provider must declare keys"
)
for raw_key in registration.keys:
key = raw_key.strip()
if not key:
raise RegistryError(
f"Module {manifest.id!r} documentation configuration provider declares an empty key"
)
if key in provider_keys:
raise RegistryError(
f"Module {manifest.id!r} declares duplicate documentation configuration key {key!r}"
)
provider_keys.add(key)
source_ids: set[str] = set()
for source in manifest.documentation_sources:
if not _INTERFACE_NAME_RE.match(source.id):
raise RegistryError(
f"Module {manifest.id!r} documentation source id must be namespaced: {source.id!r}"
)
if source.id in source_ids:
raise RegistryError(
f"Module {manifest.id!r} declares duplicate documentation source {source.id!r}"
)
source_ids.add(source.id)
if not source.label.strip():
raise RegistryError(
f"Module {manifest.id!r} documentation source {source.id!r} must have a label"
)
def _validate_manifest_identity(manifest: ModuleManifest) -> None:
@@ -353,6 +929,14 @@ def _validate_manifest_identity(manifest: ModuleManifest) -> None:
raise RegistryError(f"Module {manifest.id!r} must declare a non-empty name")
if not manifest.version.strip():
raise RegistryError(f"Module {manifest.id!r} must declare a non-empty version")
if (
manifest.permission_namespace is not None
and not _MODULE_ID_RE.match(manifest.permission_namespace)
):
raise RegistryError(
f"Module {manifest.id!r} has invalid permission namespace "
f"{manifest.permission_namespace!r}"
)
if manifest.compatibility.manifest_contract_version != SUPPORTED_MANIFEST_CONTRACT_VERSION:
raise RegistryError(
f"Module {manifest.id!r} uses unsupported manifest contract version "
@@ -368,6 +952,32 @@ def _validate_manifest_contract_lists(manifest: ModuleManifest) -> None:
_validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities)
_validate_interface_providers(manifest.id, manifest.provides_interfaces)
_validate_interface_requirements(manifest.id, manifest.requires_interfaces)
provider_ids: set[str] = set()
for registration in manifest.search_providers:
if not _INTERFACE_NAME_RE.match(registration.id):
raise RegistryError(
f"Module {manifest.id!r} search provider id must be namespaced: "
f"{registration.id!r}"
)
if registration.id in provider_ids:
raise RegistryError(
f"Module {manifest.id!r} declares duplicate search provider "
f"{registration.id!r}"
)
provider_ids.add(registration.id)
source_ids: set[str] = set()
for registration in manifest.search_sources:
if not _INTERFACE_NAME_RE.match(registration.id):
raise RegistryError(
f"Module {manifest.id!r} search source id must be "
f"namespaced: {registration.id!r}"
)
if registration.id in source_ids:
raise RegistryError(
f"Module {manifest.id!r} declares duplicate search source "
f"{registration.id!r}"
)
source_ids.add(registration.id)
def _validate_manifest_overlaps(manifest: ModuleManifest) -> None:
@@ -403,10 +1013,119 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
)
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
for route in (*frontend.routes, *frontend.settings_routes):
for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes):
_validate_frontend_route(manifest.id, route.path, route.component)
for route in (*frontend.routes, *frontend.settings_routes):
if route.surface_id is not None:
_validate_view_surface_id(manifest.id, route.surface_id)
for item in frontend.nav_items:
_validate_nav_item(manifest.id, item)
if item.surface_id is not None:
_validate_view_surface_id(manifest.id, item.surface_id)
_validate_view_surfaces(manifest)
def _validate_view_surfaces(manifest: ModuleManifest) -> None:
frontend = manifest.frontend
if frontend is None:
return
root_id = module_view_surface_id(manifest.id)
known_ids = {root_id}
_validate_view_surface_id(manifest.id, root_id)
for item in frontend.nav_items:
surface_id = item.surface_id or navigation_view_surface_id(manifest.id, item.path)
_register_view_surface_id(manifest.id, surface_id, known_ids)
for route in (*frontend.routes, *frontend.settings_routes):
surface_id = route.surface_id or route_view_surface_id(manifest.id, route.path)
_register_view_surface_id(manifest.id, surface_id, known_ids)
for surface in frontend.view_surfaces:
_validate_declared_view_surface(manifest.id, surface)
_register_view_surface_id(manifest.id, surface.id, known_ids)
for surface in frontend.view_surfaces:
_validate_view_surface_definition(
manifest.id,
surface,
root_id=root_id,
known_ids=known_ids,
)
parent_by_id = {
surface.id: surface.parent_id or root_id
for surface in frontend.view_surfaces
}
_validate_view_surface_hierarchy(manifest.id, parent_by_id)
def _register_view_surface_id(
module_id: str,
surface_id: str,
known_ids: set[str],
) -> None:
_validate_view_surface_id(module_id, surface_id)
if surface_id in known_ids:
raise RegistryError(
f"Duplicate view surface id {surface_id!r} in module {module_id!r}"
)
known_ids.add(surface_id)
def _validate_declared_view_surface(
module_id: str,
surface: ViewSurface,
) -> None:
if surface.module_id != module_id:
raise RegistryError(
f"View surface {surface.id!r} belongs to {surface.module_id!r}, "
f"not module {module_id!r}"
)
def _validate_view_surface_definition(
module_id: str,
surface: ViewSurface,
*,
root_id: str,
known_ids: set[str],
) -> None:
parent_id = surface.parent_id or root_id
if parent_id not in known_ids:
raise RegistryError(
f"View surface {surface.id!r} in module {module_id!r} "
f"references unknown parent {parent_id!r}"
)
if surface.required and not surface.default_visible:
raise RegistryError(
f"Required view surface {surface.id!r} in module {module_id!r} "
"must be visible by default"
)
def _validate_view_surface_hierarchy(
module_id: str,
parent_by_id: Mapping[str, str],
) -> None:
for surface_id in parent_by_id:
path: set[str] = set()
current_id: str | None = surface_id
while current_id in parent_by_id:
if current_id in path:
raise RegistryError(
f"View surface hierarchy in module {module_id!r} "
f"contains a cycle at {current_id!r}"
)
path.add(current_id)
current_id = parent_by_id[current_id]
def _validate_view_surface_id(module_id: str, surface_id: str) -> None:
if not validate_view_surface_id(surface_id):
raise RegistryError(
f"View surface id for module {module_id!r} is invalid: {surface_id!r}"
)
if not surface_id.startswith(f"{module_id}."):
raise RegistryError(
f"View surface id for module {module_id!r} must start with "
f"{module_id!r} followed by '.': {surface_id!r}"
)
def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
@@ -416,6 +1135,24 @@ def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component")
def _validate_public_frontend_route_uniqueness(
manifests: tuple[ModuleManifest, ...],
) -> None:
owners: dict[str, tuple[str, PublicFrontendRoute]] = {}
for manifest in manifests:
if manifest.frontend is None:
continue
for route in manifest.frontend.public_routes:
previous = owners.get(route.path)
if previous is not None:
previous_module, _previous_route = previous
raise RegistryError(
f"Duplicate public frontend route {route.path!r} in modules "
f"{previous_module!r} and {manifest.id!r}"
)
owners[route.path] = (manifest.id, route)
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list)
for manifest in manifests:
@@ -0,0 +1,605 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import StrEnum
import hashlib
import json
import os
import socket
from typing import Any
from uuid import uuid4
from sqlalchemy import BigInteger, DateTime, Index, Integer, JSON, String, UniqueConstraint, select
from sqlalchemy.orm import Mapped, Session, mapped_column
from govoplan_core.db.base import Base, TimestampMixin, utcnow
class RuntimeNodeState(StrEnum):
ACTIVE = "active"
DRAINING = "draining"
STOPPED = "stopped"
class RuntimeCoordinationError(RuntimeError):
pass
class LeaseUnavailable(RuntimeCoordinationError):
pass
class StaleFence(RuntimeCoordinationError):
pass
class RuntimeNode(Base, TimestampMixin):
__tablename__ = "core_runtime_nodes"
__table_args__ = (
UniqueConstraint(
"installation_id",
"node_id",
name="uq_core_runtime_node_installation_node",
),
Index(
"ix_core_runtime_nodes_installation_state_heartbeat",
"installation_id",
"state",
"last_heartbeat_at",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid4()),
)
installation_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
node_id: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
incarnation: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
role: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
software_version: Mapped[str] = mapped_column(String(80), nullable=False)
composition_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
queues: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
state: Mapped[str] = mapped_column(
String(30),
default=RuntimeNodeState.ACTIVE.value,
nullable=False,
index=True,
)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
last_heartbeat_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
drain_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
drain_reason: Mapped[str | None] = mapped_column(String(500))
stopped_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, default=dict, nullable=False)
class DistributedLease(Base, TimestampMixin):
__tablename__ = "core_distributed_leases"
__table_args__ = (
UniqueConstraint(
"installation_id",
"resource_key",
name="uq_core_distributed_lease_resource",
),
Index(
"ix_core_distributed_leases_expiry",
"installation_id",
"expires_at",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid4()),
)
installation_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
resource_key: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
holder_node_id: Mapped[str | None] = mapped_column(String(200), index=True)
holder_incarnation: Mapped[str | None] = mapped_column(String(36), index=True)
fencing_token: Mapped[int] = mapped_column(
BigInteger().with_variant(Integer, "sqlite"),
default=0,
nullable=False,
)
acquired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
renewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, default=dict, nullable=False)
@dataclass(frozen=True, slots=True)
class RuntimeIdentity:
installation_id: str
node_id: str
incarnation: str
role: str
software_version: str
composition_hash: str
queues: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class LeaseClaim:
installation_id: str
resource_key: str
holder_node_id: str
holder_incarnation: str
fencing_token: int
expires_at: datetime
def runtime_identity(
settings: object,
*,
software_version: str,
module_ids: tuple[str, ...] = (),
role: str | None = None,
node_id: str | None = None,
incarnation: str | None = None,
queues: tuple[str, ...] | None = None,
) -> RuntimeIdentity:
effective_role = str(
role or getattr(settings, "runtime_role", "api") or "api"
).strip().lower()
effective_node_id = str(
node_id
or getattr(settings, "runtime_node_id", None)
or os.getenv("HOSTNAME")
or socket.gethostname()
).strip()
installation_id = str(
getattr(settings, "installation_id", "govoplan-local")
or "govoplan-local"
).strip()
if not installation_id or not effective_node_id or not effective_role:
raise RuntimeCoordinationError(
"Runtime identity requires installation, node, and role identifiers"
)
effective_queues = queues
if effective_queues is None:
effective_queues = tuple(
item.strip()
for item in str(getattr(settings, "celery_queues", "") or "").split(",")
if item.strip()
)
composition_hash = hashlib.sha256(
json.dumps(sorted(module_ids), separators=(",", ":")).encode("utf-8")
).hexdigest()
return RuntimeIdentity(
installation_id=installation_id,
node_id=effective_node_id,
incarnation=incarnation or str(uuid4()),
role=effective_role,
software_version=str(software_version),
composition_hash=composition_hash,
queues=tuple(sorted(set(effective_queues))),
)
def register_runtime_node(
session: Session,
identity: RuntimeIdentity,
*,
metadata: dict[str, Any] | None = None,
now: datetime | None = None,
) -> RuntimeNode:
observed_at = _as_utc(now or utcnow())
_insert_node_placeholder(session, identity, observed_at)
node = _locked_node(session, identity.installation_id, identity.node_id)
if node is None: # pragma: no cover - guarded by insert/upsert
raise RuntimeCoordinationError("Runtime node registration was not persisted")
node.incarnation = identity.incarnation
node.role = identity.role
node.software_version = identity.software_version
node.composition_hash = identity.composition_hash
node.queues = list(identity.queues)
node.state = RuntimeNodeState.ACTIVE.value
node.started_at = observed_at
node.last_heartbeat_at = observed_at
node.drain_requested_at = None
node.drain_reason = None
node.stopped_at = None
node.metadata_ = dict(metadata or {})
session.add(node)
session.flush()
return node
def heartbeat_runtime_node(
session: Session,
identity: RuntimeIdentity,
*,
now: datetime | None = None,
metadata: dict[str, Any] | None = None,
) -> RuntimeNode:
node = _locked_node(session, identity.installation_id, identity.node_id)
if node is None or node.incarnation != identity.incarnation:
raise RuntimeCoordinationError(
"Runtime heartbeat was rejected for a stale node incarnation"
)
if node.state == RuntimeNodeState.STOPPED.value:
raise RuntimeCoordinationError("Stopped runtime node cannot heartbeat")
node.last_heartbeat_at = _as_utc(now or utcnow())
if metadata is not None:
node.metadata_ = dict(metadata)
session.add(node)
session.flush()
return node
def request_runtime_node_drain(
session: Session,
*,
installation_id: str,
node_id: str,
reason: str | None = None,
now: datetime | None = None,
) -> RuntimeNode:
node = _locked_node(session, installation_id, node_id)
if node is None:
raise RuntimeCoordinationError("Runtime node was not found")
if node.state == RuntimeNodeState.STOPPED.value:
raise RuntimeCoordinationError("Stopped runtime node cannot be drained")
node.state = RuntimeNodeState.DRAINING.value
node.drain_requested_at = _as_utc(now or utcnow())
node.drain_reason = str(reason or "operator request")[:500]
session.add(node)
session.flush()
return node
def cancel_runtime_node_drain(
session: Session,
*,
installation_id: str,
node_id: str,
) -> RuntimeNode:
node = _locked_node(session, installation_id, node_id)
if node is None:
raise RuntimeCoordinationError("Runtime node was not found")
if node.state != RuntimeNodeState.DRAINING.value:
return node
node.state = RuntimeNodeState.ACTIVE.value
node.drain_requested_at = None
node.drain_reason = None
session.add(node)
session.flush()
return node
def stop_runtime_node(
session: Session,
identity: RuntimeIdentity,
*,
now: datetime | None = None,
) -> bool:
node = _locked_node(session, identity.installation_id, identity.node_id)
if node is None or node.incarnation != identity.incarnation:
return False
observed_at = _as_utc(now or utcnow())
node.state = RuntimeNodeState.STOPPED.value
node.stopped_at = observed_at
node.last_heartbeat_at = observed_at
session.add(node)
session.flush()
return True
def runtime_node_is_draining(
session: Session,
identity: RuntimeIdentity,
) -> bool:
node = session.execute(
select(RuntimeNode).where(
RuntimeNode.installation_id == identity.installation_id,
RuntimeNode.node_id == identity.node_id,
RuntimeNode.incarnation == identity.incarnation,
)
).scalar_one_or_none()
return node is not None and node.state == RuntimeNodeState.DRAINING.value
def list_runtime_nodes(
session: Session,
*,
installation_id: str,
stale_after_seconds: int,
now: datetime | None = None,
) -> list[dict[str, Any]]:
observed_at = _as_utc(now or utcnow())
stale_before = observed_at - timedelta(seconds=max(1, stale_after_seconds))
nodes = session.execute(
select(RuntimeNode)
.where(RuntimeNode.installation_id == installation_id)
.order_by(RuntimeNode.role, RuntimeNode.node_id)
).scalars().all()
return [
{
"node_id": node.node_id,
"incarnation": node.incarnation,
"role": node.role,
"software_version": node.software_version,
"composition_hash": node.composition_hash,
"queues": list(node.queues or []),
"state": node.state,
"started_at": _iso(node.started_at),
"last_heartbeat_at": _iso(node.last_heartbeat_at),
"drain_requested_at": _iso(node.drain_requested_at),
"drain_reason": node.drain_reason,
"stopped_at": _iso(node.stopped_at),
"stale": (
node.state != RuntimeNodeState.STOPPED.value
and _as_utc(node.last_heartbeat_at) < stale_before
),
"metadata": dict(node.metadata_ or {}),
}
for node in nodes
]
def acquire_lease(
session: Session,
*,
installation_id: str,
resource_key: str,
holder_node_id: str,
holder_incarnation: str,
ttl_seconds: int,
metadata: dict[str, Any] | None = None,
now: datetime | None = None,
) -> LeaseClaim | None:
if ttl_seconds < 1:
raise ValueError("Lease TTL must be at least one second")
observed_at = _as_utc(now or utcnow())
_insert_lease_placeholder(session, installation_id, resource_key, observed_at)
lease = _locked_lease(session, installation_id, resource_key)
if lease is None: # pragma: no cover - guarded by insert/upsert
raise RuntimeCoordinationError("Distributed lease was not persisted")
same_holder = (
lease.holder_node_id == holder_node_id
and lease.holder_incarnation == holder_incarnation
)
expired = _as_utc(lease.expires_at) <= observed_at
if lease.holder_node_id is not None and not same_holder and not expired:
return None
if not same_holder or expired:
lease.fencing_token = int(lease.fencing_token or 0) + 1
lease.acquired_at = observed_at
lease.holder_node_id = holder_node_id
lease.holder_incarnation = holder_incarnation
lease.renewed_at = observed_at
lease.expires_at = observed_at + timedelta(seconds=ttl_seconds)
lease.metadata_ = dict(metadata or {})
session.add(lease)
session.flush()
return _lease_claim(lease)
def renew_lease(
session: Session,
claim: LeaseClaim,
*,
ttl_seconds: int,
now: datetime | None = None,
) -> LeaseClaim:
if ttl_seconds < 1:
raise ValueError("Lease TTL must be at least one second")
observed_at = _as_utc(now or utcnow())
lease = _locked_lease(session, claim.installation_id, claim.resource_key)
_assert_matching_fence(lease, claim, observed_at=observed_at)
lease.renewed_at = observed_at
lease.expires_at = observed_at + timedelta(seconds=ttl_seconds)
session.add(lease)
session.flush()
return _lease_claim(lease)
def release_lease(
session: Session,
claim: LeaseClaim,
*,
now: datetime | None = None,
) -> None:
observed_at = _as_utc(now or utcnow())
lease = _locked_lease(session, claim.installation_id, claim.resource_key)
_assert_matching_fence(lease, claim, observed_at=observed_at)
lease.holder_node_id = None
lease.holder_incarnation = None
lease.renewed_at = observed_at
lease.expires_at = observed_at
session.add(lease)
session.flush()
def assert_lease_fence(
session: Session,
claim: LeaseClaim,
*,
now: datetime | None = None,
) -> None:
lease = _locked_lease(session, claim.installation_id, claim.resource_key)
_assert_matching_fence(
lease,
claim,
observed_at=_as_utc(now or utcnow()),
)
def _insert_node_placeholder(
session: Session,
identity: RuntimeIdentity,
observed_at: datetime,
) -> None:
values = {
"id": str(uuid4()),
"installation_id": identity.installation_id,
"node_id": identity.node_id,
"incarnation": identity.incarnation,
"role": identity.role,
"software_version": identity.software_version,
"composition_hash": identity.composition_hash,
"queues": list(identity.queues),
"state": RuntimeNodeState.ACTIVE.value,
"started_at": observed_at,
"last_heartbeat_at": observed_at,
"metadata": {},
"created_at": observed_at,
"updated_at": observed_at,
}
_insert_do_nothing(
session,
RuntimeNode.__table__,
values,
conflict_columns=("installation_id", "node_id"),
)
def _insert_lease_placeholder(
session: Session,
installation_id: str,
resource_key: str,
observed_at: datetime,
) -> None:
values = {
"id": str(uuid4()),
"installation_id": installation_id,
"resource_key": resource_key,
"fencing_token": 0,
"expires_at": observed_at,
"metadata": {},
"created_at": observed_at,
"updated_at": observed_at,
}
_insert_do_nothing(
session,
DistributedLease.__table__,
values,
conflict_columns=("installation_id", "resource_key"),
)
def _insert_do_nothing(
session: Session,
table: Any,
values: dict[str, Any],
*,
conflict_columns: tuple[str, ...],
) -> None:
dialect = session.get_bind().dialect.name
if dialect == "postgresql":
from sqlalchemy.dialects.postgresql import insert
statement = insert(table).values(**values).on_conflict_do_nothing(
index_elements=list(conflict_columns)
)
elif dialect == "sqlite":
from sqlalchemy.dialects.sqlite import insert
statement = insert(table).values(**values).on_conflict_do_nothing(
index_elements=list(conflict_columns)
)
else: # pragma: no cover - GovOPlaN supports PostgreSQL and dev SQLite
raise RuntimeCoordinationError(
f"Distributed coordination is unsupported on {dialect!r}"
)
session.execute(statement)
session.flush()
def _locked_node(
session: Session,
installation_id: str,
node_id: str,
) -> RuntimeNode | None:
return session.execute(
select(RuntimeNode)
.where(
RuntimeNode.installation_id == installation_id,
RuntimeNode.node_id == node_id,
)
.with_for_update()
).scalar_one_or_none()
def _locked_lease(
session: Session,
installation_id: str,
resource_key: str,
) -> DistributedLease | None:
return session.execute(
select(DistributedLease)
.where(
DistributedLease.installation_id == installation_id,
DistributedLease.resource_key == resource_key,
)
.with_for_update()
).scalar_one_or_none()
def _assert_matching_fence(
lease: DistributedLease | None,
claim: LeaseClaim,
*,
observed_at: datetime,
) -> None:
if (
lease is None
or lease.holder_node_id != claim.holder_node_id
or lease.holder_incarnation != claim.holder_incarnation
or int(lease.fencing_token) != claim.fencing_token
or _as_utc(lease.expires_at) <= observed_at
):
raise StaleFence(
f"Lease fence is stale for resource {claim.resource_key!r}"
)
def _lease_claim(lease: DistributedLease) -> LeaseClaim:
if lease.holder_node_id is None or lease.holder_incarnation is None:
raise LeaseUnavailable("Distributed lease has no active holder")
return LeaseClaim(
installation_id=lease.installation_id,
resource_key=lease.resource_key,
holder_node_id=lease.holder_node_id,
holder_incarnation=lease.holder_incarnation,
fencing_token=int(lease.fencing_token),
expires_at=_as_utc(lease.expires_at),
)
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _iso(value: datetime | None) -> str | None:
return _as_utc(value).isoformat() if value is not None else None
__all__ = [
"DistributedLease",
"LeaseClaim",
"LeaseUnavailable",
"RuntimeCoordinationError",
"RuntimeIdentity",
"RuntimeNode",
"RuntimeNodeState",
"StaleFence",
"acquire_lease",
"assert_lease_fence",
"cancel_runtime_node_drain",
"heartbeat_runtime_node",
"list_runtime_nodes",
"register_runtime_node",
"release_lease",
"renew_lease",
"request_runtime_node_drain",
"runtime_identity",
"runtime_node_is_draining",
"stop_runtime_node",
]
+383
View File
@@ -0,0 +1,383 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
SANCTIONS_SNAPSHOT_CONTRACT_VERSION = "1"
SANCTIONS_SCREENING_CONTRACT_VERSION = "1"
CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS = (
"connectors.sanctionsSnapshotProvider"
)
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING = (
"riskCompliance.sanctionsScreeningProvider"
)
@dataclass(frozen=True, slots=True)
class SanctionsSnapshotReference:
ref: str
tenant_id: str
provider_id: str
publisher: str
jurisdiction: str
list_type: str
source_id: str
source_version: str
publication_at: datetime | None
effective_at: datetime | None
acquired_at: datetime
content_type: str
byte_count: int
sha256: str
parser_version: str
raw_evidence_ref: str
connector_run_id: str
signature_evidence: Mapping[str, object] = field(
default_factory=dict
)
licence_notes: str | None = None
trust_notes: str | None = None
transport_evidence: Mapping[str, object] = field(
default_factory=dict
)
contract_version: str = SANCTIONS_SNAPSHOT_CONTRACT_VERSION
def __post_init__(self) -> None:
if self.contract_version != SANCTIONS_SNAPSHOT_CONTRACT_VERSION:
raise ValueError(
"Unsupported sanctions snapshot contract version."
)
required = (
self.ref,
self.tenant_id,
self.provider_id,
self.publisher,
self.jurisdiction,
self.list_type,
self.source_id,
self.source_version,
self.content_type,
self.sha256,
self.parser_version,
self.raw_evidence_ref,
self.connector_run_id,
)
if not all(value.strip() for value in required):
raise ValueError(
"Sanctions snapshot references require complete provenance."
)
if self.byte_count < 1:
raise ValueError(
"Sanctions snapshot evidence must not be empty."
)
if (
len(self.sha256) != 64
or any(character not in "0123456789abcdef" for character in self.sha256)
):
raise ValueError(
"Sanctions snapshot SHA-256 evidence is invalid."
)
@dataclass(frozen=True, slots=True)
class SanctionsSnapshotPayload:
snapshot: SanctionsSnapshotReference
content: bytes
def __post_init__(self) -> None:
if len(self.content) != self.snapshot.byte_count:
raise ValueError(
"Sanctions snapshot content length does not match metadata."
)
@dataclass(frozen=True, slots=True)
class SanctionsScreeningSubject:
subject_type: Literal["person", "entity"]
primary_name: str | None = None
subject_ref: str | None = None
aliases: tuple[str, ...] = ()
identifiers: tuple[Mapping[str, str], ...] = ()
dates: tuple[str, ...] = ()
addresses: tuple[Mapping[str, str], ...] = ()
def __post_init__(self) -> None:
if self.subject_type not in {"person", "entity"}:
raise ValueError(
"Sanctions screening subjects must be a person or entity."
)
if not str(self.primary_name or "").strip() and not any(
str(value.get("value") or "").strip()
for value in self.identifiers
):
raise ValueError(
"A sanctions screening subject needs a name or identifier."
)
@dataclass(frozen=True, slots=True)
class SanctionsScreeningPolicy:
fuzzy_threshold: float = 0.88
max_snapshot_age_days: int = 7
max_candidates: int = 100
failure_policy: Literal["block", "review", "degraded"] = "block"
def __post_init__(self) -> None:
if not 0.8 <= self.fuzzy_threshold <= 1:
raise ValueError(
"Sanctions screening fuzzy threshold must be between 0.8 and 1."
)
if not 1 <= self.max_snapshot_age_days <= 365:
raise ValueError(
"Sanctions snapshot age must be between 1 and 365 days."
)
if not 1 <= self.max_candidates <= 100:
raise ValueError(
"Sanctions screening candidate limit must be between 1 and 100."
)
if self.failure_policy not in {"block", "review", "degraded"}:
raise ValueError(
"Sanctions screening failure policy is not supported."
)
@dataclass(frozen=True, slots=True)
class SanctionsScreeningRequest:
list_snapshot_id: str
idempotency_key: str
subject: SanctionsScreeningSubject
policy: SanctionsScreeningPolicy = field(
default_factory=SanctionsScreeningPolicy
)
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
def __post_init__(self) -> None:
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
raise ValueError(
"Unsupported sanctions screening contract version."
)
if not self.list_snapshot_id.strip():
raise ValueError(
"A sanctions list snapshot is required for screening."
)
if not self.idempotency_key.strip():
raise ValueError(
"A sanctions screening idempotency key is required."
)
@dataclass(frozen=True, slots=True)
class SanctionsScreeningEvidence:
ref: str
run_id: str
outcome: str
candidate_count: int
list_snapshot_id: str
source_version: str
subject_fingerprint: str
matcher_version: str
normalization_version: str
policy_version: str
completed_at: datetime | None
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
def __post_init__(self) -> None:
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
raise ValueError(
"Unsupported sanctions screening evidence version."
)
if self.ref != f"risk-screening:{self.run_id}":
raise ValueError(
"Sanctions screening evidence reference is invalid."
)
if self.candidate_count < 0:
raise ValueError(
"Sanctions screening candidate count cannot be negative."
)
@dataclass(frozen=True, slots=True)
class SanctionsScreeningFreshnessRequest:
evidence_ref: str
current_subject: SanctionsScreeningSubject | None = None
expected_list_snapshot_id: str | None = None
policy: SanctionsScreeningPolicy = field(
default_factory=SanctionsScreeningPolicy
)
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
def __post_init__(self) -> None:
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
raise ValueError(
"Unsupported sanctions screening freshness contract version."
)
if not self.evidence_ref.startswith("risk-screening:"):
raise ValueError(
"Sanctions screening evidence reference is invalid."
)
@dataclass(frozen=True, slots=True)
class SanctionsScreeningFreshness:
evidence: SanctionsScreeningEvidence
fresh: bool
reasons: tuple[str, ...]
checked_at: datetime
current_list_snapshot_id: str | None
gate_decision: Literal["allow", "block", "review", "degraded"]
gate_reasons: tuple[str, ...]
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
def __post_init__(self) -> None:
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
raise ValueError(
"Unsupported sanctions screening freshness version."
)
if self.fresh != (not self.reasons):
raise ValueError(
"Sanctions screening freshness reasons are inconsistent."
)
if self.gate_decision not in {
"allow",
"block",
"review",
"degraded",
}:
raise ValueError(
"Sanctions screening gate decision is invalid."
)
@dataclass(frozen=True, slots=True)
class SanctionsScreeningResult:
evidence: SanctionsScreeningEvidence
freshness: SanctionsScreeningFreshness
created: bool
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
def __post_init__(self) -> None:
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
raise ValueError(
"Unsupported sanctions screening result version."
)
if self.evidence != self.freshness.evidence:
raise ValueError(
"Sanctions screening result evidence is inconsistent."
)
@runtime_checkable
class SanctionsSnapshotProvider(Protocol):
def list_snapshots(
self,
session: object,
principal: object,
*,
limit: int = 100,
) -> Sequence[SanctionsSnapshotReference]:
...
def get_snapshot(
self,
session: object,
principal: object,
*,
snapshot_ref: str,
) -> SanctionsSnapshotReference | None:
...
def read_snapshot(
self,
session: object,
principal: object,
*,
snapshot_ref: str,
) -> SanctionsSnapshotPayload:
...
@runtime_checkable
class SanctionsScreeningProvider(Protocol):
def request_screening(
self,
session: object,
principal: object,
request: SanctionsScreeningRequest,
) -> SanctionsScreeningResult:
...
def check_freshness(
self,
session: object,
principal: object,
request: SanctionsScreeningFreshnessRequest,
) -> SanctionsScreeningFreshness:
...
def sanctions_snapshot_provider(
registry: object | None,
) -> SanctionsSnapshotProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(
CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS
)
):
return None
provider = registry.capability(
CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS
)
return (
provider
if isinstance(provider, SanctionsSnapshotProvider)
else None
)
def sanctions_screening_provider(
registry: object | None,
) -> SanctionsScreeningProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING
)
):
return None
provider = registry.capability(
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING
)
return (
provider
if isinstance(provider, SanctionsScreeningProvider)
else None
)
__all__ = [
"CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS",
"CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING",
"SANCTIONS_SCREENING_CONTRACT_VERSION",
"SANCTIONS_SNAPSHOT_CONTRACT_VERSION",
"SanctionsScreeningEvidence",
"SanctionsScreeningFreshness",
"SanctionsScreeningFreshnessRequest",
"SanctionsScreeningPolicy",
"SanctionsScreeningProvider",
"SanctionsScreeningRequest",
"SanctionsScreeningResult",
"SanctionsScreeningSubject",
"SanctionsSnapshotPayload",
"SanctionsSnapshotProvider",
"SanctionsSnapshotReference",
"sanctions_screening_provider",
"sanctions_snapshot_provider",
]
+473
View File
@@ -0,0 +1,473 @@
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.external_references import ExternalObjectReference
from govoplan_core.core.modules import ModuleContext
SearchContextKind = Literal["global", "module", "resource"]
SearchVisibility = Literal["tenant", "restricted"]
SearchIndexChangeKind = Literal["upsert", "delete"]
SEARCH_SOURCE_CONTRACT_VERSION = "1"
@dataclass(frozen=True, slots=True)
class SearchQuery:
text: str
tenant_id: str
module_ids: tuple[str, ...] = ()
resource_types: tuple[str, ...] = ()
context_kind: SearchContextKind = "global"
context_id: str | None = None
limit: int = 25
offset: int = 0
cursor: str | None = None
language: str = "simple"
def __post_init__(self) -> None:
normalized_text = self.text.strip()
if len(normalized_text) > 500:
raise ValueError("Search text is limited to 500 characters.")
if not self.tenant_id.strip():
raise ValueError("Search queries require a tenant.")
if not 1 <= self.limit <= 200:
raise ValueError("Search result limits must be between 1 and 200.")
if self.offset < 0:
raise ValueError("Search offsets cannot be negative.")
if self.cursor and self.offset:
raise ValueError(
"Search cursor and offset pagination cannot be combined."
)
if not self.language.replace("_", "").isalnum():
raise ValueError("Search language configuration is invalid.")
if len(self.module_ids) > 50 or len(self.resource_types) > 50:
raise ValueError(
"Search queries support at most 50 module and type filters."
)
object.__setattr__(self, "text", normalized_text)
@dataclass(frozen=True, slots=True)
class SearchResult:
provider_id: str
module_id: str
resource_type: str
resource_id: str
title: str
url: str
summary: str | None = None
score: float = 0.0
highlights: tuple[str, ...] = ()
breadcrumbs: tuple[str, ...] = ()
external_reference: ExternalObjectReference | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
source_revision: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class SearchResourceReference:
tenant_id: str
module_id: str
resource_type: str
resource_id: str
def __post_init__(self) -> None:
for field_name in (
"tenant_id",
"module_id",
"resource_type",
"resource_id",
):
if not str(getattr(self, field_name) or "").strip():
raise ValueError(
f"Search resource reference {field_name} is required."
)
@property
def key(self) -> str:
values = (
self.tenant_id,
self.module_id,
self.resource_type,
self.resource_id,
)
return "|".join(
f"{len(value)}:{value}"
for value in values
)
@dataclass(frozen=True, slots=True)
class SearchResourceType:
provider_id: str
module_id: str
resource_type: str
label: str
index_version: int = 1
language: str = "simple"
requires_authorization_recheck: bool = True
def __post_init__(self) -> None:
if not all(
value.strip()
for value in (
self.provider_id,
self.module_id,
self.resource_type,
self.label,
)
):
raise ValueError(
"Search resource type identifiers and label are required."
)
if self.index_version < 1:
raise ValueError("Search index versions start at one.")
@dataclass(frozen=True, slots=True)
class SearchDocument:
tenant_id: str
module_id: str
resource_type: str
resource_id: str
title: str
url: str
summary: str | None = None
body: str | None = None
keywords: tuple[str, ...] = ()
visibility: SearchVisibility = "restricted"
acl_tokens: tuple[str, ...] = ()
external_reference: ExternalObjectReference | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
provider_id: str | None = None
source_revision: str = "1"
change_cursor: str | None = None
source_updated_at: datetime | None = None
language: str = "simple"
index_version: int = 1
requires_authorization_recheck: bool = False
def __post_init__(self) -> None:
required = {
"tenant_id": self.tenant_id,
"module_id": self.module_id,
"resource_type": self.resource_type,
"resource_id": self.resource_id,
"title": self.title,
"url": self.url,
}
for field_name, value in required.items():
if not str(value or "").strip():
raise ValueError(f"Search document {field_name} is required.")
limits = {
"module_id": 100,
"resource_type": 100,
"resource_id": 255,
"title": 500,
"url": 1500,
"summary": 4_000,
"body": 200_000,
"source_revision": 255,
"change_cursor": 500,
"language": 32,
}
for field_name, limit in limits.items():
value = getattr(self, field_name)
if value is not None and len(str(value)) > limit:
raise ValueError(
f"Search document {field_name} is limited to "
f"{limit} characters."
)
if len(self.keywords) > 100 or any(
len(keyword) > 200
for keyword in self.keywords
):
raise ValueError(
"Search documents support at most 100 bounded keywords."
)
if len(self.acl_tokens) > 500 or any(
len(token) > 500
for token in self.acl_tokens
):
raise ValueError(
"Search documents support at most 500 bounded ACL tokens."
)
if self.visibility == "restricted" and not self.acl_tokens:
raise ValueError(
"Restricted search documents require at least one ACL token."
)
if not self.source_revision.strip():
raise ValueError(
"Search documents require a stable source revision."
)
if self.index_version < 1:
raise ValueError("Search document index versions start at one.")
if (
self.requires_authorization_recheck
and not str(self.provider_id or "").strip()
):
raise ValueError(
"Search documents requiring authorization rechecks must "
"name their source provider."
)
if self.provider_id is not None and len(self.provider_id) > 200:
raise ValueError(
"Search document provider_id is limited to 200 characters."
)
@property
def reference(self) -> SearchResourceReference:
return SearchResourceReference(
tenant_id=self.tenant_id,
module_id=self.module_id,
resource_type=self.resource_type,
resource_id=self.resource_id,
)
@dataclass(frozen=True, slots=True)
class SearchAuthorizationRequest:
reference: SearchResourceReference
source_revision: str
@dataclass(frozen=True, slots=True)
class SearchBackfillRequest:
tenant_id: str
provider_id: str
resource_type: str
rebuild_id: str
cursor: str | None = None
limit: int = 100
def __post_init__(self) -> None:
if not 1 <= self.limit <= 500:
raise ValueError(
"Search backfill page size must be between 1 and 500."
)
@dataclass(frozen=True, slots=True)
class SearchBackfillPage:
documents: tuple[SearchDocument, ...]
next_cursor: str | None
complete: bool
high_watermark: str | None = None
def __post_init__(self) -> None:
if len(self.documents) > 500:
raise ValueError(
"Search backfill providers returned more than 500 documents."
)
if not self.complete and not self.next_cursor:
raise ValueError(
"Incomplete search backfill pages require a next cursor."
)
@dataclass(frozen=True, slots=True)
class SearchIndexChange:
change_id: str
provider_id: str
kind: SearchIndexChangeKind
reference: SearchResourceReference
source_revision: str
cursor: str
document: SearchDocument | None = None
occurred_at: datetime | None = None
def __post_init__(self) -> None:
if not all(
value.strip()
for value in (
self.change_id,
self.provider_id,
self.source_revision,
self.cursor,
)
):
raise ValueError(
"Search index changes require stable identity and revision."
)
if self.kind == "upsert":
if (
self.document is None
or self.document.reference != self.reference
or self.document.provider_id != self.provider_id
or self.document.source_revision
!= self.source_revision
or self.document.change_cursor != self.cursor
):
raise ValueError(
"Search upserts require a matching document."
)
elif self.document is not None:
raise ValueError(
"Search deletes must not contain a document."
)
@runtime_checkable
class SearchProvider(Protocol):
def search(
self,
session: object,
principal: object,
*,
query: SearchQuery,
) -> Sequence[SearchResult]:
"""Return only results the principal may currently read."""
@runtime_checkable
class SearchIndexWriter(Protocol):
def upsert_document(
self,
session: object,
principal: object,
*,
document: SearchDocument,
) -> None:
...
def delete_document(
self,
session: object,
principal: object,
*,
tenant_id: str,
module_id: str,
resource_type: str,
resource_id: str,
) -> bool:
...
def enqueue_change(
self,
session: object,
*,
change: SearchIndexChange,
) -> bool:
...
@runtime_checkable
class SearchSourceProvider(Protocol):
def resource_types(self) -> Sequence[SearchResourceType]:
...
def backfill(
self,
session: object,
*,
request: SearchBackfillRequest,
) -> SearchBackfillPage:
...
def authorize(
self,
session: object,
principal: object,
*,
requests: Sequence[SearchAuthorizationRequest],
) -> Mapping[str, bool]:
"""Return an explicit decision for every requested reference key."""
SearchProviderFactory = Callable[[ModuleContext], SearchProvider]
SearchSourceProviderFactory = Callable[
[ModuleContext],
SearchSourceProvider,
]
@dataclass(frozen=True, slots=True)
class SearchProviderRegistration:
id: str
factory: SearchProviderFactory
resource_types: tuple[str, ...] = ()
order: int = 100
def create(self, context: ModuleContext) -> SearchProvider:
provider = self.factory(context)
if not isinstance(provider, SearchProvider):
raise TypeError(
f"Search provider {self.id!r} does not implement SearchProvider."
)
return provider
@dataclass(frozen=True, slots=True)
class RegisteredSearchProvider:
module_id: str
registration: SearchProviderRegistration
@dataclass(frozen=True, slots=True)
class SearchSourceProviderRegistration:
id: str
factory: SearchSourceProviderFactory
order: int = 100
def create(self, context: ModuleContext) -> SearchSourceProvider:
provider = self.factory(context)
if not isinstance(provider, SearchSourceProvider):
raise TypeError(
f"Search source {self.id!r} does not implement "
"SearchSourceProvider."
)
return provider
@dataclass(frozen=True, slots=True)
class RegisteredSearchSourceProvider:
module_id: str
registration: SearchSourceProviderRegistration
CAPABILITY_SEARCH_INDEX_WRITER = "search.index_writer"
def search_index_writer(registry: object | None) -> SearchIndexWriter | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(CAPABILITY_SEARCH_INDEX_WRITER)
):
return None
writer = registry.capability(CAPABILITY_SEARCH_INDEX_WRITER)
return writer if isinstance(writer, SearchIndexWriter) else None
__all__ = [
"CAPABILITY_SEARCH_INDEX_WRITER",
"SEARCH_SOURCE_CONTRACT_VERSION",
"RegisteredSearchProvider",
"RegisteredSearchSourceProvider",
"SearchAuthorizationRequest",
"SearchBackfillPage",
"SearchBackfillRequest",
"SearchContextKind",
"SearchDocument",
"SearchIndexChange",
"SearchIndexChangeKind",
"SearchIndexWriter",
"SearchProvider",
"SearchProviderFactory",
"SearchProviderRegistration",
"SearchQuery",
"SearchResourceReference",
"SearchResourceType",
"SearchResult",
"SearchSourceProvider",
"SearchSourceProviderFactory",
"SearchSourceProviderRegistration",
"SearchVisibility",
"search_index_writer",
]
+264
View File
@@ -0,0 +1,264 @@
from __future__ import annotations
import csv
import io
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
CAPABILITY_CONNECTORS_TABULAR_SOURCES = "connectors.tabularSources"
CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER = "connectors.tabularSnapshotWriter"
class TabularSourceError(ValueError):
"""Stable base error for provider-neutral tabular source operations."""
class TabularSourceNotFoundError(TabularSourceError):
pass
class TabularSourceAccessError(TabularSourceError):
pass
class TabularSourceValidationError(TabularSourceError):
pass
def parse_tabular_csv(
csv_text: str,
*,
delimiter: str = ",",
max_rows: int = 10_000,
) -> tuple[Mapping[str, object], ...]:
"""Parse a bounded CSV document into JSON-compatible tabular rows."""
if len(delimiter) != 1:
raise TabularSourceValidationError("CSV delimiter must be one character.")
try:
reader = csv.DictReader(io.StringIO(csv_text), delimiter=delimiter)
original_headers, normalized_headers = _csv_headers(reader.fieldnames)
rows: list[dict[str, object]] = []
for row in reader:
_validate_csv_row_shape(row)
if _csv_row_is_empty(row, original_headers):
continue
if len(rows) >= max_rows:
raise TabularSourceValidationError(
f"CSV snapshots are limited to {max_rows:,} rows."
)
rows.append(_csv_row(row, original_headers, normalized_headers))
return tuple(rows)
except csv.Error as exc:
raise TabularSourceValidationError(f"CSV input could not be parsed: {exc}") from exc
def _csv_headers(
fieldnames: Sequence[str | None] | None,
) -> tuple[tuple[str, ...], tuple[str, ...]]:
if not fieldnames:
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
original = tuple(str(name or "") for name in fieldnames)
normalized = tuple(
name.strip().lstrip("\ufeff") if position == 0 else name.strip()
for position, name in enumerate(original)
)
if any(not name for name in normalized):
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
if len(set(normalized)) != len(normalized):
raise TabularSourceValidationError("CSV column names must be unique.")
return original, normalized
def _validate_csv_row_shape(row: Mapping[str | None, object]) -> None:
extras = row.get(None)
if isinstance(extras, list) and any(str(value or "").strip() for value in extras):
raise TabularSourceValidationError(
"A CSV row contains more values than the header defines."
)
def _csv_row_is_empty(
row: Mapping[str | None, str | list[str] | None],
headers: Sequence[str],
) -> bool:
return all(
value is None or isinstance(value, list) or not value.strip()
for value in (row.get(header) for header in headers)
)
def _csv_row(
row: Mapping[str | None, str | list[str] | None],
original_headers: Sequence[str],
normalized_headers: Sequence[str],
) -> dict[str, object]:
return {
normalized: _csv_scalar(value if isinstance(value, str) else None)
for original, normalized in zip(
original_headers,
normalized_headers,
strict=True,
)
for value in (row.get(original),)
}
def _csv_scalar(value: str | None) -> object:
if value is None:
return None
text = value.strip()
if not text:
return None
lowered = text.casefold()
if lowered in {"true", "false"}:
return lowered == "true"
if re.fullmatch(r"-?(?:0|[1-9][0-9]*)", text):
return int(text)
if re.fullmatch(r"-?(?:0|[1-9][0-9]*)\.[0-9]+", text):
return float(text)
return text
@dataclass(frozen=True, slots=True)
class TabularColumn:
name: str
data_type: str
nullable: bool = True
@dataclass(frozen=True, slots=True)
class TabularSource:
"""Opaque, policy-filtered source reference exposed to consuming modules."""
ref: str
provider: str
source_name: str
name: str
description: str | None = None
schema: tuple[TabularColumn, ...] = ()
schema_version: str = "1"
fingerprint: str = ""
row_count: int | None = None
byte_count: int | None = None
updated_at: datetime | None = None
capabilities: tuple[str, ...] = ("read",)
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class TabularReadRequest:
source_ref: str
limit: int = 250
offset: int = 0
columns: tuple[str, ...] = ()
expected_fingerprint: str | None = None
@dataclass(frozen=True, slots=True)
class TabularReadResult:
source: TabularSource
rows: tuple[Mapping[str, object], ...]
total_rows: int
truncated: bool
@dataclass(frozen=True, slots=True)
class TabularSnapshotInput:
name: str
source_name: str
rows: tuple[Mapping[str, object], ...]
description: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class TabularSourceProvider(Protocol):
"""Principal-aware catalogue and bounded reader for tabular sources."""
def list_sources(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
) -> Sequence[TabularSource]:
...
def get_source(
self,
session: object,
principal: object,
*,
source_ref: str,
) -> TabularSource | None:
...
def read_source(
self,
session: object,
principal: object,
*,
request: TabularReadRequest,
) -> TabularReadResult:
...
@runtime_checkable
class TabularSnapshotWriter(Protocol):
"""Optional capability for importing bounded static tabular snapshots."""
def create_snapshot(
self,
session: object,
principal: object,
*,
snapshot: TabularSnapshotInput,
) -> TabularSource:
...
def tabular_source_provider(registry: object | None) -> TabularSourceProvider | None:
capability = _capability(registry, CAPABILITY_CONNECTORS_TABULAR_SOURCES)
return capability if isinstance(capability, TabularSourceProvider) else None
def tabular_snapshot_writer(registry: object | None) -> TabularSnapshotWriter | None:
capability = _capability(registry, CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER)
return capability if isinstance(capability, TabularSnapshotWriter) else None
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
__all__ = [
"CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER",
"CAPABILITY_CONNECTORS_TABULAR_SOURCES",
"TabularColumn",
"TabularReadRequest",
"TabularReadResult",
"TabularSnapshotInput",
"TabularSnapshotWriter",
"TabularSource",
"TabularSourceAccessError",
"TabularSourceError",
"TabularSourceNotFoundError",
"TabularSourceProvider",
"TabularSourceValidationError",
"parse_tabular_csv",
"tabular_snapshot_writer",
"tabular_source_provider",
]
+349
View File
@@ -0,0 +1,349 @@
from __future__ import annotations
import hashlib
import logging
import re
import threading
import time
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from typing import Protocol
from redis import Redis
from redis.exceptions import RedisError
logger = logging.getLogger(__name__)
_NAMESPACE_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]{0,99}$")
_REDIS_INCREMENT_SCRIPT = """
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
local ttl = redis.call('TTL', KEYS[1])
return {count, ttl}
"""
@dataclass(frozen=True, slots=True)
class FixedWindowBucket:
count: int = 0
retry_after_seconds: int = 0
@dataclass(frozen=True, slots=True)
class ThrottleDimension:
"""One independently limited dimension without putting its value in keys."""
namespace: str
subject: str
limit: int
@dataclass(frozen=True, slots=True)
class ThrottleDecision:
allowed: bool
retry_after_seconds: int = 0
class FixedWindowStore(Protocol):
def read(self, key: str) -> FixedWindowBucket: ...
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket: ...
def delete(self, key: str) -> None: ...
class InMemoryFixedWindowStore:
"""Bounded process-local store for development and distributed-store loss."""
def __init__(
self,
*,
max_entries: int = 10_000,
clock: Callable[[], float] = time.monotonic,
) -> None:
self._entries: dict[str, tuple[int, float]] = {}
self._lock = threading.Lock()
self._max_entries = max(2, max_entries)
self._clock = clock
def read(self, key: str) -> FixedWindowBucket:
now = self._clock()
with self._lock:
entry = self._active_entry(key, now=now)
if entry is None:
return FixedWindowBucket()
count, expires_at = entry
return FixedWindowBucket(
count=count,
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
)
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
now = self._clock()
with self._lock:
entry = self._active_entry(key, now=now)
if entry is None:
self._make_room(now=now, incoming_key=key)
count = 1
expires_at = now + window_seconds
else:
count = entry[0] + 1
expires_at = entry[1]
self._entries[key] = (count, expires_at)
return FixedWindowBucket(
count=count,
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
)
def delete(self, key: str) -> None:
with self._lock:
self._entries.pop(key, None)
def _active_entry(self, key: str, *, now: float) -> tuple[int, float] | None:
entry = self._entries.get(key)
if entry is None:
return None
if entry[1] <= now:
self._entries.pop(key, None)
return None
return entry
def _make_room(self, *, now: float, incoming_key: str) -> None:
if incoming_key in self._entries or len(self._entries) < self._max_entries:
return
for key, (_count, expires_at) in tuple(self._entries.items()):
if expires_at <= now:
self._entries.pop(key, None)
while len(self._entries) >= self._max_entries:
self._entries.pop(next(iter(self._entries)))
class RedisFixedWindowStore:
"""Atomic Redis fixed-window counters shared across API workers."""
def __init__(self, redis_url: str) -> None:
self._client = Redis.from_url(
redis_url,
decode_responses=True,
socket_connect_timeout=0.25,
socket_timeout=0.25,
health_check_interval=30,
)
def read(self, key: str) -> FixedWindowBucket:
pipeline = self._client.pipeline(transaction=False)
pipeline.get(key)
pipeline.ttl(key)
raw_count, raw_ttl = pipeline.execute()
return FixedWindowBucket(
count=int(raw_count or 0),
retry_after_seconds=max(0, int(raw_ttl or 0)),
)
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
result = self._client.eval(
_REDIS_INCREMENT_SCRIPT,
1,
key,
window_seconds,
)
if not isinstance(result, (list, tuple)) or len(result) != 2:
raise RedisError("Unexpected fixed-window throttle response from Redis")
return FixedWindowBucket(
count=int(result[0]),
retry_after_seconds=max(1, int(result[1])),
)
def delete(self, key: str) -> None:
self._client.delete(key)
class ResilientFixedWindowStore:
"""Mirror locally, prefer Redis, and fall back safely during an outage."""
def __init__(
self,
primary: FixedWindowStore | None,
fallback: FixedWindowStore,
*,
retry_seconds: int = 30,
clock: Callable[[], float] = time.monotonic,
) -> None:
self._primary = primary
self._fallback = fallback
self._retry_seconds = max(1, retry_seconds)
self._clock = clock
self._primary_unavailable_until = 0.0
self._state_lock = threading.Lock()
def read(self, key: str) -> FixedWindowBucket:
fallback_result = self._fallback.read(key)
primary = self._available_primary()
if primary is None:
return fallback_result
try:
return _stricter_bucket(primary.read(key), fallback_result)
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
self._mark_primary_unavailable(exc)
return fallback_result
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
fallback_result = self._fallback.increment(
key,
window_seconds=window_seconds,
)
primary = self._available_primary()
if primary is None:
return fallback_result
try:
primary_result = primary.increment(
key,
window_seconds=window_seconds,
)
return _stricter_bucket(primary_result, fallback_result)
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
self._mark_primary_unavailable(exc)
return fallback_result
def delete(self, key: str) -> None:
self._fallback.delete(key)
primary = self._available_primary()
if primary is None:
return
try:
primary.delete(key)
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
self._mark_primary_unavailable(exc)
def _available_primary(self) -> FixedWindowStore | None:
if self._primary is None:
return None
with self._state_lock:
if self._clock() < self._primary_unavailable_until:
return None
return self._primary
def _mark_primary_unavailable(self, exc: Exception) -> None:
should_log = False
with self._state_lock:
now = self._clock()
if now >= self._primary_unavailable_until:
should_log = True
self._primary_unavailable_until = now + self._retry_seconds
if should_log:
logger.warning(
"Distributed throttling is unavailable; using the process-local fallback (%s)",
type(exc).__name__,
)
def _stricter_bucket(
first: FixedWindowBucket,
second: FixedWindowBucket,
) -> FixedWindowBucket:
return FixedWindowBucket(
count=max(first.count, second.count),
retry_after_seconds=max(
first.retry_after_seconds,
second.retry_after_seconds,
),
)
class FixedWindowThrottle:
def __init__(
self,
store: FixedWindowStore,
*,
window_seconds: int,
key_prefix: str = "govoplan:throttle:v1",
) -> None:
self._store = store
self._window_seconds = max(1, window_seconds)
self._key_prefix = key_prefix.rstrip(":")
def check(self, dimensions: Iterable[ThrottleDimension]) -> ThrottleDecision:
return self._decision(tuple(dimensions), increment=False)
def record(self, dimensions: Iterable[ThrottleDimension]) -> ThrottleDecision:
return self._decision(tuple(dimensions), increment=True)
def reset(self, dimensions: Iterable[ThrottleDimension]) -> None:
for dimension in tuple(dimensions):
self._store.delete(self._key(dimension))
def _decision(
self,
dimensions: tuple[ThrottleDimension, ...],
*,
increment: bool,
) -> ThrottleDecision:
if not dimensions:
raise ValueError("At least one throttle dimension is required")
blocked_retry_after = 0
for dimension in dimensions:
if dimension.limit < 1:
raise ValueError("Throttle limits must be positive")
key = self._key(dimension)
state = (
self._store.increment(key, window_seconds=self._window_seconds)
if increment
else self._store.read(key)
)
if state.count >= dimension.limit:
blocked_retry_after = max(
blocked_retry_after,
max(1, state.retry_after_seconds),
)
return ThrottleDecision(
allowed=blocked_retry_after == 0,
retry_after_seconds=blocked_retry_after,
)
def _key(self, dimension: ThrottleDimension) -> str:
namespace = dimension.namespace.strip().casefold()
if not _NAMESPACE_PATTERN.fullmatch(namespace):
raise ValueError("Throttle namespaces must use lowercase letters, digits, '.', '_' or '-'")
subject_digest = hashlib.sha256(dimension.subject.encode("utf-8")).hexdigest()
return f"{self._key_prefix}:{namespace}:{subject_digest}"
def build_fixed_window_throttle(
*,
redis_url: str | None,
window_seconds: int,
redis_retry_seconds: int = 30,
max_local_entries: int = 10_000,
key_prefix: str = "govoplan:throttle:v1",
) -> FixedWindowThrottle:
primary = (
RedisFixedWindowStore(redis_url)
if redis_url is not None and redis_url.strip()
else None
)
store = ResilientFixedWindowStore(
primary,
InMemoryFixedWindowStore(max_entries=max_local_entries),
retry_seconds=redis_retry_seconds,
)
return FixedWindowThrottle(
store,
window_seconds=window_seconds,
key_prefix=key_prefix,
)
__all__ = [
"FixedWindowBucket",
"FixedWindowStore",
"FixedWindowThrottle",
"InMemoryFixedWindowStore",
"RedisFixedWindowStore",
"ResilientFixedWindowStore",
"ThrottleDecision",
"ThrottleDimension",
"build_fixed_window_throttle",
]
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
import re
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Literal, Protocol, runtime_checkable
VIEWS_MODULE_ID = "views"
CAPABILITY_VIEWS_RESOLVER = "views.resolver"
VIEW_SURFACE_CONTRACT_VERSION = "1"
ViewSurfaceKind = Literal[
"module",
"navigation",
"route",
"section",
"action",
"selector",
]
_SURFACE_ID_RE = re.compile(r"^[a-z][a-z0-9_.-]{2,159}$")
_SURFACE_SLUG_RE = re.compile(r"[^a-z0-9]+")
@dataclass(frozen=True, slots=True)
class ViewSurface:
id: str
module_id: str
kind: ViewSurfaceKind
label: str
parent_id: str | None = None
description: str | None = None
order: int = 100
default_visible: bool = True
required: bool = False
@dataclass(frozen=True, slots=True)
class EffectiveView:
view_id: str | None
revision_id: str | None
name: str | None
visible_surface_ids: frozenset[str]
locked: bool = False
projection_active: bool = False
provenance: tuple[dict[str, object], ...] = ()
@runtime_checkable
class ViewResolver(Protocol):
def resolve_effective_view(
self,
session: object,
*,
tenant_id: str,
account_id: str,
group_ids: Iterable[str] = (),
workflow_view_id: str | None = None,
workflow_revision_id: str | None = None,
workflow_surface_ids: Iterable[str] = (),
) -> EffectiveView: ...
def views_resolver(registry: object | None) -> ViewResolver | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_VIEWS_RESOLVER)
):
return None
capability = registry.capability(CAPABILITY_VIEWS_RESOLVER)
return capability if isinstance(capability, ViewResolver) else None
def module_view_surface_id(module_id: str) -> str:
return f"{module_id}.module"
def navigation_view_surface_id(module_id: str, path: str) -> str:
return f"{module_id}.nav.{_surface_slug(path)}"
def route_view_surface_id(module_id: str, path: str) -> str:
return f"{module_id}.route.{_surface_slug(path)}"
def validate_view_surface_id(value: str) -> bool:
return bool(_SURFACE_ID_RE.fullmatch(value))
def _surface_slug(value: str) -> str:
normalized = _SURFACE_SLUG_RE.sub(".", value.strip().lower()).strip(".")
return normalized or "root"
__all__ = [
"CAPABILITY_VIEWS_RESOLVER",
"EffectiveView",
"VIEWS_MODULE_ID",
"VIEW_SURFACE_CONTRACT_VERSION",
"ViewResolver",
"ViewSurface",
"ViewSurfaceKind",
"module_view_surface_id",
"navigation_view_surface_id",
"route_view_surface_id",
"validate_view_surface_id",
"views_resolver",
]
+223
View File
@@ -0,0 +1,223 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
CAPABILITY_VOTING_BALLOTS = "voting.ballots"
CAPABILITY_VOTING_PROVIDER_PREFIX = "voting.provider."
VOTING_ASSURANCE_RECORDED = "recorded"
VOTING_ASSURANCE_CONFIDENTIAL = "confidential"
VOTING_ASSURANCE_SECRET = "secret"
VOTING_ASSURANCE_EXTERNAL_CERTIFIED = "external_certified"
class VotingCapabilityError(ValueError):
"""Stable error raised by Voting capability implementations."""
def voting_provider_capability(provider_id: str) -> str:
normalized = str(provider_id or "").strip().lower()
if not normalized or any(
character not in "abcdefghijklmnopqrstuvwxyz0123456789_.-"
for character in normalized
):
raise VotingCapabilityError("Voting provider id is invalid.")
return f"{CAPABILITY_VOTING_PROVIDER_PREFIX}{normalized}"
@dataclass(frozen=True, slots=True)
class VotingOption:
key: str
label: str
description: str | None = None
@dataclass(frozen=True, slots=True)
class VotingElector:
subject_id: str
label: str | None = None
weight: int = 1
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class VotingBallotCreateCommand:
title: str
method: str
assurance_profile: str
options: tuple[VotingOption, ...]
electorate: tuple[VotingElector, ...]
description: str | None = None
context_module: str | None = None
context_resource_type: str | None = None
context_resource_id: str | None = None
quorum_weight: int = 0
threshold_numerator: int = 1
threshold_denominator: int = 2
allow_replacement: bool = True
opens_at: datetime | None = None
closes_at: datetime | None = None
provider_id: str | None = None
provider_ballot_ref: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class VotingCastCommand:
selections: tuple[str, ...]
idempotency_key: str
elector_id: str | None = None
@dataclass(frozen=True, slots=True)
class VotingBallotRef:
id: str
revision: int
state: str
assurance_profile: str
definition_sha256: str | None = None
electorate_sha256: str | None = None
@dataclass(frozen=True, slots=True)
class VotingReceipt:
ballot_id: str
revision: int
receipt_sha256: str
cast_at: datetime
replaced_previous: bool = False
replayed: bool = False
@dataclass(frozen=True, slots=True)
class VotingResult:
ballot_id: str
revision: int
counts: Mapping[str, int]
weighted_counts: Mapping[str, int]
cast_count: int
cast_weight: int
eligible_count: int
eligible_weight: int
quorum_met: bool
threshold_met: bool
winning_options: tuple[str, ...]
result_sha256: str
evidence: tuple[Mapping[str, object], ...] = ()
@dataclass(frozen=True, slots=True)
class ExternalVotingFinalizationRequest:
tenant_id: str
ballot_id: str
provider_ballot_ref: str
definition_sha256: str
electorate_sha256: str
options: tuple[VotingOption, ...]
eligible_count: int
eligible_weight: int
requested_at: datetime
idempotency_key: str
@runtime_checkable
class ExternalVotingProvider(Protocol):
"""Provider boundary for confidential, secret, or certified voting.
Providers return aggregate results and evidence only. Raw ballots and
provider credentials must not cross this boundary.
"""
def finalize_ballot(
self,
session: object,
principal: object,
*,
request: ExternalVotingFinalizationRequest,
) -> VotingResult: ...
@runtime_checkable
class VotingBallotProvider(Protocol):
def create_ballot(
self,
session: object,
principal: object,
*,
command: VotingBallotCreateCommand,
idempotency_key: str,
) -> VotingBallotRef: ...
def get_ballot(
self,
session: object,
principal: object,
*,
ballot_id: str,
) -> Mapping[str, object] | None: ...
def open_ballot(
self,
session: object,
principal: object,
*,
ballot_id: str,
expected_revision: int,
idempotency_key: str,
) -> VotingBallotRef: ...
def cast_ballot(
self,
session: object,
principal: object,
*,
ballot_id: str,
command: VotingCastCommand,
) -> VotingReceipt: ...
def close_ballot(
self,
session: object,
principal: object,
*,
ballot_id: str,
expected_revision: int,
idempotency_key: str,
) -> VotingResult: ...
def certify_ballot(
self,
session: object,
principal: object,
*,
ballot_id: str,
expected_revision: int,
evidence: Sequence[Mapping[str, object]],
idempotency_key: str,
) -> VotingBallotRef: ...
__all__ = [
"CAPABILITY_VOTING_BALLOTS",
"CAPABILITY_VOTING_PROVIDER_PREFIX",
"ExternalVotingFinalizationRequest",
"ExternalVotingProvider",
"VOTING_ASSURANCE_CONFIDENTIAL",
"VOTING_ASSURANCE_EXTERNAL_CERTIFIED",
"VOTING_ASSURANCE_RECORDED",
"VOTING_ASSURANCE_SECRET",
"VotingBallotCreateCommand",
"VotingBallotProvider",
"VotingBallotRef",
"VotingCapabilityError",
"VotingCastCommand",
"VotingElector",
"VotingOption",
"VotingReceipt",
"VotingResult",
"voting_provider_capability",
]
+268
View File
@@ -0,0 +1,268 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
import hashlib
import json
from typing import Protocol, runtime_checkable
from govoplan_core.core.events import PlatformEvent
CAPABILITY_WORKFLOW_RUNTIME_WORKER = "workflow.runtimeWorker"
CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS = "workflow.definitionContributions"
CAPABILITY_WORKFLOW_ORCHESTRATION = "workflow.orchestration"
CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER = "workflow.triggerDispatcher"
@dataclass(frozen=True, slots=True)
class WorkflowDefinitionContribution:
"""Versioned, module-owned Workflow Engine baseline definition."""
origin_module_id: str
origin_module_version: str
definition_key: str
name: str
graph: Mapping[str, object]
contribution_schema_version: str = "1"
description: str | None = None
definition_kind: str = "flow"
scope_type: str = "system"
inherit_to_lower_scopes: bool = True
allow_start: bool = True
allow_reuse: bool = True
allow_automation: bool = False
execution_mode: str = "hybrid"
view_id: str | None = None
view_revision_id: str | None = None
bpmn_xml: str | None = None
bpmn_adapter_id: str = "govoplan.native.bpmn"
bpmn_adapter_version: str | None = None
activate_on_install: bool = True
required_capabilities: tuple[str, ...] = ()
required_interfaces: tuple[str, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
policy_metadata: Mapping[str, object] = field(default_factory=dict)
content_hash: str | None = None
@dataclass(frozen=True, slots=True)
class WorkflowStandardStartRequest:
tenant_id: str
origin_module_id: str
definition_key: str
idempotency_key: str
input: Mapping[str, object] = field(default_factory=dict)
actor_id: str | None = None
correlation_id: str | None = None
start_origin: str = "user"
@dataclass(frozen=True, slots=True)
class WorkflowCurrentStepResolution:
action: str
expected_step_id: str | None = None
actor_id: str | None = None
output: Mapping[str, object] = field(default_factory=dict)
evidence: tuple[str, ...] = ()
comment: str | None = None
@dataclass(frozen=True, slots=True)
class WorkflowInstanceRef:
id: str
tenant_id: str
definition_id: str
definition_revision_id: str
definition_revision: int
definition_hash: str
status: str
current_step_id: str | None = None
current_node_id: str | None = None
replayed: bool = False
def workflow_definition_contribution_hash(
contribution: WorkflowDefinitionContribution,
) -> str:
"""Return the stable content hash, excluding package release provenance."""
payload = {
"schema_version": contribution.contribution_schema_version,
"definition_key": contribution.definition_key,
"name": contribution.name,
"description": contribution.description,
"graph": contribution.graph,
"definition_kind": contribution.definition_kind,
"scope_type": contribution.scope_type,
"inherit_to_lower_scopes": contribution.inherit_to_lower_scopes,
"allow_start": contribution.allow_start,
"allow_reuse": contribution.allow_reuse,
"allow_automation": contribution.allow_automation,
"execution_mode": contribution.execution_mode,
"view_id": contribution.view_id,
"view_revision_id": contribution.view_revision_id,
"bpmn_xml": contribution.bpmn_xml,
"bpmn_adapter_id": contribution.bpmn_adapter_id,
"bpmn_adapter_version": contribution.bpmn_adapter_version,
"activate_on_install": contribution.activate_on_install,
"required_capabilities": contribution.required_capabilities,
"required_interfaces": contribution.required_interfaces,
"metadata": contribution.metadata,
"policy_metadata": contribution.policy_metadata,
}
encoded = json.dumps(
payload,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
)
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
@runtime_checkable
class WorkflowRuntimeWorker(Protocol):
def reconcile_pending(
self,
session: object,
*,
now: datetime | None = None,
limit: int = 50,
) -> Mapping[str, object]: ...
@runtime_checkable
class WorkflowTriggerDispatcher(Protocol):
"""Durable schedule/event start and wait-resumption boundary."""
def dispatch_due(
self,
session: object,
*,
now: datetime | None = None,
limit: int = 50,
) -> Mapping[str, object]: ...
def ingest_event(
self,
session: object,
*,
event: PlatformEvent,
) -> Mapping[str, object]: ...
@runtime_checkable
class WorkflowDefinitionContributionProvider(Protocol):
def reconcile(
self,
session: object,
*,
tenant_ids: Sequence[str] = (),
) -> Mapping[str, object]: ...
@runtime_checkable
class WorkflowOrchestrationProvider(Protocol):
def start_standard(
self,
session: object,
principal: object,
*,
request: WorkflowStandardStartRequest,
) -> WorkflowInstanceRef: ...
def resolve_current_step(
self,
session: object,
principal: object,
*,
tenant_id: str,
instance_id: str,
resolution: WorkflowCurrentStepResolution,
) -> WorkflowInstanceRef: ...
def get_instance(
self,
session: object,
*,
tenant_id: str,
instance_id: str,
) -> WorkflowInstanceRef: ...
def workflow_runtime_worker(
registry: object | None,
) -> WorkflowRuntimeWorker | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_WORKFLOW_RUNTIME_WORKER)
):
return None
capability = registry.capability(CAPABILITY_WORKFLOW_RUNTIME_WORKER)
return capability if isinstance(capability, WorkflowRuntimeWorker) else None
def workflow_trigger_dispatcher(
registry: object | None,
) -> WorkflowTriggerDispatcher | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER)
):
return None
capability = registry.capability(CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER)
return capability if isinstance(capability, WorkflowTriggerDispatcher) else None
def workflow_definition_contribution_provider(
registry: object | None,
) -> WorkflowDefinitionContributionProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS)
):
return None
capability = registry.capability(CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS)
return (
capability
if isinstance(capability, WorkflowDefinitionContributionProvider)
else None
)
def workflow_orchestration_provider(
registry: object | None,
) -> WorkflowOrchestrationProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_WORKFLOW_ORCHESTRATION)
):
return None
capability = registry.capability(CAPABILITY_WORKFLOW_ORCHESTRATION)
return capability if isinstance(capability, WorkflowOrchestrationProvider) else None
__all__ = [
"CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS",
"CAPABILITY_WORKFLOW_ORCHESTRATION",
"CAPABILITY_WORKFLOW_RUNTIME_WORKER",
"CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER",
"WorkflowCurrentStepResolution",
"WorkflowDefinitionContribution",
"WorkflowDefinitionContributionProvider",
"WorkflowInstanceRef",
"WorkflowOrchestrationProvider",
"WorkflowRuntimeWorker",
"WorkflowTriggerDispatcher",
"WorkflowStandardStartRequest",
"workflow_definition_contribution_hash",
"workflow_definition_contribution_provider",
"workflow_orchestration_provider",
"workflow_runtime_worker",
"workflow_trigger_dispatcher",
]
+4 -1
View File
@@ -32,6 +32,9 @@ def create_all_tables() -> None:
# model metadata with the shared SQLAlchemy base before create_all runs.
from govoplan_core.admin import models as core_admin_models # noqa: F401
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401
from govoplan_core.core import recovery as core_recovery_models # noqa: F401
from govoplan_core.core import runtime_coordination as core_runtime_models # noqa: F401
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules)
@@ -65,7 +68,7 @@ def bootstrap_dev_data(
api_key_secret: str | None = None,
tenant_slug: str = "default",
user_email: str = "admin@example.local",
user_password: str = "dev-admin",
user_password: str = "dev-admin", # noqa: S107 - development bootstrap only.
) -> BootstrapResult:
tenant = session.query(Tenant).filter(Tenant.slug == tenant_slug).one_or_none()
if tenant is None:
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
from contextlib import contextmanager
import hashlib
import time
from collections.abc import Iterator
from sqlalchemy import create_engine, text
from sqlalchemy.engine import make_url
from sqlalchemy.exc import SQLAlchemyError
class MigrationLockTimeout(RuntimeError):
pass
def migration_lock_key(installation_id: str, migration_track: str) -> int:
payload = f"govoplan\0{installation_id}\0{migration_track}".encode("utf-8")
return int.from_bytes(hashlib.sha256(payload).digest()[:8], "big", signed=True)
@contextmanager
def deployment_migration_lock(
database_url: str,
*,
installation_id: str,
migration_track: str,
timeout_seconds: float = 900.0,
poll_seconds: float = 2.0,
) -> Iterator[None]:
"""Serialize all release migrations before coordination tables are available."""
if timeout_seconds < 0 or poll_seconds <= 0:
raise ValueError("Migration lock timeout must be non-negative and polling positive")
if make_url(database_url).get_backend_name() != "postgresql":
yield
return
key = migration_lock_key(installation_id, migration_track)
engine = create_engine(database_url)
connection = engine.connect()
acquired = False
try:
deadline = time.monotonic() + timeout_seconds
while True:
acquired = bool(
connection.execute(
text("SELECT pg_try_advisory_lock(:key)"),
{"key": key},
).scalar_one()
)
if acquired:
break
if time.monotonic() >= deadline:
raise MigrationLockTimeout(
"Timed out waiting for the deployment-wide database migration lock"
)
time.sleep(min(poll_seconds, max(0.05, deadline - time.monotonic())))
yield
finally:
if acquired:
try:
connection.execute(
text("SELECT pg_advisory_unlock(:key)"),
{"key": key},
)
except SQLAlchemyError:
# Closing the physical connection releases session locks.
pass
connection.close()
engine.dispose()
__all__ = [
"MigrationLockTimeout",
"deployment_migration_lock",
"migration_lock_key",
]
+57 -3
View File
@@ -1,12 +1,13 @@
from __future__ import annotations
from collections.abc import Mapping
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, replace
import json
import logging
import os
from pathlib import Path
import re
import sysconfig
from typing import Any
from alembic import command
@@ -17,6 +18,9 @@ from sqlalchemy import create_engine, inspect, text
from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
from govoplan_core.core import recovery as core_recovery_models # noqa: F401 - populate core metadata
from govoplan_core.core import runtime_coordination as core_runtime_models # noqa: F401 - populate core metadata
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.modules import ModuleMigrationTaskContext, ModuleMigrationTaskResult
@@ -215,7 +219,10 @@ def _registered_module_registry(
server_config = get_server_config()
active_database_url = database_url or settings.database_url
if active_database_url:
configure_database(active_database_url)
# Registry planning may target a different database than the currently
# configured handle. The global handle is replaced either way, so
# dispose its superseded pool instead of leaking open DBAPI connections.
configure_database(active_database_url, dispose_previous=True)
active_manifest_factories = manifest_factories or tuple(server_config.manifest_factories)
raw_enabled_modules = tuple(enabled_modules) if enabled_modules is not None else load_startup_enabled_modules(server_config.enabled_modules)
candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
@@ -459,11 +466,13 @@ def _jsonable_migration_task_details(value: Mapping[str, Any]) -> Mapping[str, A
def _repo_root() -> Path:
packaged_root = Path(__file__).resolve().parents[3]
installed_runtime_root = Path(sysconfig.get_path("data")) / "govoplan_core_runtime"
configured = os.environ.get("GOVOPLAN_CORE_SOURCE_ROOT")
candidates = [
Path(configured).expanduser() if configured else None,
Path.cwd(),
packaged_root,
installed_runtime_root,
]
for candidate in candidates:
if candidate is None:
@@ -583,6 +592,51 @@ def database_revision(database_url: str | None = None) -> str | None:
engine.dispose()
def database_migration_heads(
database_url: str | None = None,
) -> tuple[str, ...]:
url = database_url or settings.database_url
engine = create_engine(url)
try:
with engine.connect() as connection:
return tuple(
sorted(MigrationContext.configure(connection).get_current_heads())
)
finally:
engine.dispose()
def configured_migration_heads(
database_url: str | None = None,
*,
enabled_modules: tuple[str, ...] | list[str] | None = None,
manifest_factories: tuple[ManifestFactory, ...] = (),
migration_track: str | None = None,
) -> tuple[str, ...]:
config = alembic_config(
database_url=database_url,
enabled_modules=enabled_modules,
manifest_factories=manifest_factories,
migration_track=migration_track,
)
return tuple(sorted(ScriptDirectory.from_config(config).get_heads()))
def database_is_at_configured_heads(
database_url: str | None = None,
*,
enabled_modules: tuple[str, ...] | list[str] | None = None,
manifest_factories: tuple[ManifestFactory, ...] = (),
migration_track: str | None = None,
) -> bool:
return database_migration_heads(database_url) == configured_migration_heads(
database_url,
enabled_modules=enabled_modules,
manifest_factories=manifest_factories,
migration_track=migration_track,
)
def _has_columns(inspector, table_name: str, required: set[str]) -> bool:
try:
actual = {column["name"] for column in inspector.get_columns(table_name)}
@@ -634,7 +688,7 @@ def _backfill_user_lock_state_for_create_all_schema(database_url: str) -> None:
def _row_count(connection, table_name: str) -> int:
quoted = _quoted_table_name(connection, table_name)
statement = text(f"SELECT COUNT(*) FROM {quoted}") # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
statement = text(f"SELECT COUNT(*) FROM {quoted}") # noqa: S608 # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
return int(connection.execute(statement).scalar_one())
+42
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from collections.abc import Generator, Mapping
import os
from typing import Any
from sqlalchemy import create_engine
@@ -10,6 +11,14 @@ from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.db.query_metrics import instrument_engine
_POOL_SETTING_BOUNDS: dict[str, tuple[int, int]] = {
"GOVOPLAN_DB_POOL_SIZE": (1, 100),
"GOVOPLAN_DB_MAX_OVERFLOW": (0, 200),
"GOVOPLAN_DB_POOL_TIMEOUT_SECONDS": (1, 300),
"GOVOPLAN_DB_POOL_RECYCLE_SECONDS": (0, 86_400),
}
def default_connect_args(database_url: str) -> dict[str, Any]:
return {"check_same_thread": False} if database_url.startswith("sqlite") else {}
@@ -24,9 +33,42 @@ def create_database_engine(
merged_connect_args = dict(default_connect_args(database_url))
if connect_args:
merged_connect_args.update(connect_args)
if not database_url.startswith("sqlite"):
kwargs.setdefault(
"pool_size",
_pool_setting("GOVOPLAN_DB_POOL_SIZE", 5),
)
kwargs.setdefault(
"max_overflow",
_pool_setting("GOVOPLAN_DB_MAX_OVERFLOW", 10),
)
kwargs.setdefault(
"pool_timeout",
_pool_setting("GOVOPLAN_DB_POOL_TIMEOUT_SECONDS", 30),
)
kwargs.setdefault(
"pool_recycle",
_pool_setting("GOVOPLAN_DB_POOL_RECYCLE_SECONDS", 1800),
)
return instrument_engine(create_engine(database_url, pool_pre_ping=pool_pre_ping, connect_args=merged_connect_args, **kwargs))
def _pool_setting(name: str, default: int) -> int:
raw = os.environ.get(name)
if raw is None:
return default
try:
value = int(raw)
except ValueError as exc:
raise ValueError(f"{name} must be an integer") from exc
minimum, maximum = _POOL_SETTING_BOUNDS[name]
if not minimum <= value <= maximum:
raise ValueError(
f"{name} must be between {minimum} and {maximum}",
)
return value
class DatabaseHandle:
def __init__(self, database_url: str, *, engine: Engine | None = None) -> None:
self.database_url = database_url

Some files were not shown because too many files have changed in this diff Show More