Compare commits

...
187 Commits
Author SHA1 Message Date
zemion c6f6faf64f feat(core): add datasource lifecycle governance contracts
Module Package Release / publish-packages (push) Successful in 13s
2026-08-22 19:37:44 +02:00
zemion 1c3ee9e8c7 Release Core v0.1.35 configuration package safeguards
Module Package Release / publish-packages (push) Successful in 13s
2026-08-22 18:05:33 +02:00
zemion aa91063211 Release Core v0.1.34 with JMAP mail contracts
Module Package Release / publish-packages (push) Successful in 14s
2026-08-22 17:08:54 +02:00
zemion fa2d5d40dd Release Core v0.1.33 with redirect-sensitive headers
Module Package Release / publish-packages (push) Successful in 13s
2026-08-22 16:11:02 +02:00
zemion 6ccef162f6 Release Core v0.1.32 with bounded HTTP request bodies
Module Package Release / publish-packages (push) Successful in 14s
2026-08-22 14:32:24 +02:00
zemion 48dac139a5 feat(wiki): compose governed Wiki WebUI
Module Package Release / publish-packages (push) Successful in 12s
2026-08-22 13:32:20 +02:00
zemion a090e5af20 feat(tickets): add optional integration contracts and WebUI composition
Module Package Release / publish-packages (push) Successful in 13s
2026-08-22 12:06:04 +02:00
zemion 0c1358b862 feat(policy): add delegation and escalation contracts
Module Package Release / publish-packages (push) Failing after 6s
2026-08-22 03:12:30 +02:00
zemion a9035c4c3b feat: add Campaign work orchestration contract
Module Package Release / publish-packages (push) Failing after 6s
2026-08-22 02:15:32 +02:00
zemion 137c7c005f test(core): recognize Campaign work integrations
Module Package Release / publish-packages (push) Successful in 12s
2026-08-22 01:06:12 +02:00
zemion 8eeea968f2 chore(release): load Campaign 0.1.22
Module Package Release / publish-packages (push) Successful in 12s
2026-08-22 01:01:35 +02:00
zemion 0ca6568005 chore(release): load Campaign 0.1.21
Module Package Release / publish-packages (push) Successful in 12s
2026-08-22 00:54:00 +02:00
zemion af90db44c9 chore(webui): load Campaign collaboration release
Module Package Release / publish-packages (push) Successful in 12s
2026-08-22 00:18:06 +02:00
zemion 5de46e9c0e chore(webui): load Files folder sync release
Module Package Release / publish-packages (push) Successful in 12s
2026-08-21 22:50:48 +02:00
zemion 1d9b677c1b chore(webui): load IDM relationship administration release
Module Package Release / publish-packages (push) Successful in 12s
2026-08-21 22:06:29 +02:00
zemion 54178ee56c chore(webui): load Access credential help release
Module Package Release / publish-packages (push) Successful in 12s
2026-08-21 21:15:31 +02:00
zemion 10e7597612 feat(core): define datasource visibility policy contract
Module Package Release / publish-packages (push) Failing after 6s
2026-08-21 20:31:51 +02:00
zemion 142ccbc587 feat(core): define managed tabular file contract
Module Package Release / publish-packages (push) Successful in 12s
2026-08-21 19:29:48 +02:00
zemion f75ad48d78 test(core): isolate entitlement navigation fixture 2026-08-21 19:26:00 +02:00
zemion 5a9e8f79f9 test(webui): cover contextual help in the browser 2026-08-21 17:36:06 +02:00
zemion fbea74a74b feat(core): define durable datasource artifact contract 2026-08-21 17:36:05 +02:00
zemion 925dc33696 feat(docs): define semantic subject contract 2026-08-21 14:55:58 +02:00
zemion 4b0737e1cd feat: support autonomous campaign schedule results 2026-08-20 21:10:04 +02:00
zemion 4f4007aff1 feat: define policy impact subject contract 2026-08-20 20:27:17 +02:00
zemion 8e687c4420 feat: define bulk governance projection contract 2026-08-20 19:46:53 +02:00
zemion 604f20eed7 feat: classify audit evidence export scope 2026-08-20 18:00:18 +02:00
zemion 6a2da94e47 feat: load connector governance webui 2026-08-20 12:46:01 +02:00
zemion e121ca900e feat: load identity administration webui 2026-08-20 12:27:37 +02:00
zemion 79629c5a2c feat: define campaign archive encryption policy contract 2026-08-20 12:12:27 +02:00
zemion 026e451aa4 feat: configure scheduling self-enrollment limits 2026-08-20 11:45:07 +02:00
zemion f11c675d11 feat: govern accessible appearance overrides 2026-08-20 10:50:55 +02:00
zemion 0fae09ba3c feat: extend calendar scheduling reconciliation contract 2026-08-20 07:44:16 +02:00
zemion 8f642bd618 feat: govern effective appearance defaults 2026-08-20 07:03:28 +02:00
zemion 6643c8fc1e feat: add validated appearance palettes 2026-08-20 06:26:52 +02:00
zemion fd90b60430 feat: centralize access explanation subject selection 2026-08-20 06:16:43 +02:00
zemion 0aae6f0539 feat(postbox): schedule lifecycle notification reconciliation 2026-08-20 04:58:35 +02:00
zemion be7b79612c feat(postbox): expose governed grouping policy 2026-08-20 04:22:29 +02:00
zemion 557c77670b feat(postbox): expose configurable protection contracts 2026-08-20 03:42:58 +02:00
zemion d277218784 feat(webui): preserve state in shared widget launches 2026-08-20 02:39:41 +02:00
zemion cf16a7b27a feat(core): add layered side rail preferences 2026-08-20 01:47:04 +02:00
zemion 51bf14f376 docs(core): define collaboration connector strategy 2026-08-19 23:15:35 +02:00
zemion 8d9bcfd8b5 test(core): prove Views cannot grant access 2026-08-19 22:55:19 +02:00
zemion 3c7a593f63 feat(templates): carry content field requirements 2026-08-19 21:54:51 +02:00
zemion 9d1352ba30 feat(mail): define standard IMAP folder mappings 2026-08-19 21:27:42 +02:00
zemion 4cf2bfeb3e feat(operations): add runtime work status contract 2026-08-19 20:52:44 +02:00
zemion 94c94fefb4 test(webui): cover View-focused Quick Access catalogues 2026-08-19 20:14:02 +02:00
zemion d600bca374 test: make full discovery optional-state independent 2026-08-19 19:20:05 +02:00
zemion ffaab543d2 feat(webui): govern actions, quick access, and metrics
Refs #264, #285, #289
2026-08-19 18:47:46 +02:00
zemion 41db78c201 Define semantic page archetypes 2026-08-19 14:26:25 +02:00
zemion c042244da8 Define semantic page action layouts 2026-08-19 13:33:51 +02:00
zemion 5a2e99f496 feat: add status and payment capability contracts 2026-08-19 12:33:34 +02:00
zemion 8a925782ab feat: add UI conformance and launch context 2026-08-18 21:32:25 +02:00
zemion 7685a103e8 Centralize shared WebUI structural primitives 2026-08-18 13:17:31 +02:00
zemion ee5c881df9 Centralize metric and description layouts 2026-08-18 11:30:39 +02:00
zemion 6814a41ae4 Add shared WebUI layout primitives 2026-08-18 10:42:55 +02:00
zemion dd7ad4d9c7 feat: add shared workspace layouts 2026-08-18 02:17:13 +02:00
zemion 934db6d44b feat: centralize shared page layouts 2026-08-18 01:03:33 +02:00
zemion d307e29145 feat: add exact credential help contexts 2026-08-17 19:51:51 +02:00
zemion ff88142471 feat: add exact retention help contexts 2026-08-17 17:41:45 +02:00
zemion 887e9beb9e docs: update meta documentation links 2026-08-17 16:52:53 +02:00
zemion e6457b3f6b feat: add governed DSAR workflow 2026-08-07 14:53:07 +02:00
zemion eb0c01c5d2 feat: expose typed deployment capability receipts 2026-08-07 11:15:49 +02:00
zemion 40cc012124 feat(modules): validate catalog permission declarations 2026-08-07 01:59:24 +02:00
zemion 44196f5620 Validate module catalog provenance and availability 2026-08-06 22:42:08 +02:00
zemion 9ceb1b8c22 Add trusted public module catalog installs 2026-08-06 21:12:54 +02:00
zemion 32c234fbdb Add product presentation extension contracts 2026-08-06 19:02:53 +02:00
zemion d65d7a8e5f Add shared work-item provider contracts 2026-08-06 16:06:17 +02:00
zemion b5f5be15f6 Add governed form evidence contract 2026-08-06 12:42:20 +02:00
zemion f5949427cc feat(records): define archive transfer provider contract 2026-08-06 05:36:11 +02:00
zemion 5d1287735e feat(core): add provider-neutral records filing contract 2026-08-06 01:43:08 +02:00
zemion 7ea0cb8655 Fix responsive DataGrid contraction 2026-08-05 22:42:21 +02:00
zemion b553513c9f Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:08:17 +02:00
zemion b5a4eb177a Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:34:47 +02:00
zemion f1a5be2a93 Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:53:46 +02:00
zemion add7a99f6d feat: add temporal context and contextual help 2026-08-05 00:03:31 +02:00
zemion 982ef636b8 Release v0.1.15
Module Package Release / publish-packages (push) Successful in 13s
2026-08-04 15:22:52 +02:00
zemion 9aad49f16d Make package publication retries hash-safe 2026-08-04 14:19:04 +02:00
zemion 2b5c14385d Add Voting provider assurance contract 2026-08-04 14:01:05 +02:00
zemion bca3e46293 Publish npm artifacts from explicit local paths 2026-08-04 14:00:57 +02:00
zemion f09d2bf9df fix(ci): normalize historical package refs 2026-08-04 13:34:00 +02:00
zemion 702421be48 fix(ci): rely on protected release boundary 2026-08-04 13:32:28 +02:00
zemion bb471df21c fix(ci): bind Gitea action context explicitly 2026-08-04 13:28:28 +02:00
zemion bfb0d7d7c9 Allow Dataflow datasets to pin published runs 2026-08-04 12:48:37 +02:00
zemion 1974bf1a2b Define bounded tabular source contracts 2026-08-04 12:05:09 +02:00
zemion 0c9bf6758c Add secure password generator 2026-08-04 11:07:55 +02:00
zemion 25da7d49a9 Add controlled first-admin enrollment 2026-08-04 10:07:10 +02:00
zemion 40c10089ab Enforce tenant module entitlements beyond requests 2026-08-04 09:29:36 +02:00
zemion d6e7c8b0b1 Add governed module and interface controls 2026-08-04 05:20:47 +02:00
zemion 5bc7d748f8 Add protected package release workflow 2026-08-04 04:14:03 +02:00
zemion 7117673ecc Add durable search event indexing contract 2026-08-04 03:03:15 +02:00
zemion 14351b0c94 Register trust and encryption WebUI modules 2026-08-04 01:27:58 +02:00
zemion ad57fad1ea Reject duplicate module migration revisions 2026-08-03 15:04:18 +02:00
zemion fa32cca03f Complete guided Core configuration patterns 2026-08-03 10:16:26 +02:00
zemion 2d0551a845 Explain disabled mail connection tests 2026-08-03 10:05:59 +02:00
zemion bb84122061 Expose object modification evidence 2026-08-03 09:23:15 +02:00
zemion b823a22b9b Link contextual guidance to Docs 2026-08-03 07:33:57 +02:00
zemion 70fc6da811 Localize shared blocker guidance 2026-08-03 07:22:16 +02:00
zemion 729b84d3af Fence and reconcile module lifecycle effects 2026-08-03 07:02:07 +02:00
zemion b962f6756e Extend action recovery reconciliation 2026-08-03 06:37:45 +02:00
zemion 79d00b84e3 Commit verified recovery projections atomically 2026-08-03 06:09:52 +02:00
zemion 842be5edb5 Commit atomic domain and recovery state together 2026-08-03 05:43:39 +02:00
zemion 7e59a7f2b3 Resolve unknown recovery outcomes from evidence 2026-08-03 05:00:08 +02:00
zemion 5bfbe9a887 Bridge legacy WebUI router imports 2026-08-03 03:59:08 +02:00
zemion 01f91154e0 Add durable external-effect runtime identity 2026-08-03 03:47:41 +02:00
zemion 6c2940aebc Refresh shared contract test fixtures 2026-08-03 03:07:59 +02:00
zemion 670693bde8 Support repository-root WebUI packages 2026-08-03 03:07:51 +02:00
zemion bca6a7c8aa Implement durable module recovery operations 2026-08-03 03:07:42 +02:00
zemion 21c1fa49b6 Add worker delivery acceptance probe 2026-08-03 00:20:51 +02:00
zemion 435b924fd9 Extend calendar invitation capability contract 2026-08-02 16:38:34 +02:00
zemion af5c6af0e7 Define connector runtime preview contract 2026-08-02 14:54:44 +02:00
zemion c6ef644842 Add typed IDM relationship contracts 2026-08-02 14:44:41 +02:00
zemion 5783d43547 Validate Campaign template contracts 2026-08-02 13:58:49 +02:00
zemion e4d2d10c7e Add template and generated artifact contracts 2026-08-02 12:37:58 +02:00
zemion fe62fd4644 Validate Campaign Distribution Lists contract 2026-08-02 11:53:36 +02:00
zemion 9ecdc6d713 Add contact point resolution contract 2026-08-02 06:20:18 +02:00
zemion ca35aad286 Add external calendar profile contract 2026-08-02 05:59:06 +02:00
zemion d6255f9f8f feat: harden multi-host runtime coordination 2026-08-02 05:30:11 +02:00
zemion b58c9c55cf feat: define governed report provider contract 2026-08-02 05:30:06 +02:00
zemion 3c4bcc28f1 refactor: consolidate shared catalog and API helpers 2026-08-02 05:30:02 +02:00
zemion 972c681650 feat: finalize encryption and voting provider contracts 2026-08-02 03:40:44 +02:00
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
433 changed files with 79032 additions and 3044 deletions
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+2
View File
@@ -149,6 +149,8 @@ webui/.module-test-build/
webui/.policy-test-build/
webui/.template-preview-test-build/
webui/.import-test-build/
webui/dist-conformance/
webui/test-results/
# Security audit reports
audit-reports/
+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.
+24 -9
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.
@@ -106,7 +117,7 @@ CI runs the `ci` profile in report-only mode and uploads `audit-reports/` as an
artifact. Once the baseline is clean, set `SECURITY_AUDIT_FAIL_ON_FINDINGS=1`
or pass `--strict` locally to turn findings into a failing gate.
`govoplan_core.devserver` enables the development bootstrap before loading settings. In dev, startup migrations create or upgrade the schema and the bootstrap creates the default development login if needed. Explicitly setting `DEV_BOOTSTRAP_ENABLED=false` disables this convenience. Production deployments should use migrations and managed database provisioning instead.
`govoplan_core.devserver` enables the development bootstrap before loading settings. In dev, startup migrations create or upgrade the schema and the bootstrap creates the default development login if needed. Explicitly setting `DEV_BOOTSTRAP_ENABLED=false` disables this convenience. Production deployments use the separate, expiring single-use flow exposed by `python -m govoplan_core.commands.first_admin`; it cannot enable or consume development bootstrap credentials.
To verify the effective runtime paths and bootstrap behavior without starting uvicorn, run the smoke mode:
@@ -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,23 @@
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"
/ "a36d8e4f9b12_german_reference_locale.py"
)
_spec = spec_from_file_location("govoplan_german_reference_locale_migration", _path)
if _spec is None or _spec.loader is None:
raise RuntimeError(f"Unable to load migration implementation from {_path}")
_module = module_from_spec(_spec)
_spec.loader.exec_module(_module)
revision = _module.revision
down_revision = _module.down_revision
branch_labels = _module.branch_labels
depends_on = _module.depends_on
upgrade = _module.upgrade
downgrade = _module.downgrade
@@ -0,0 +1,23 @@
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"
/ "b47e6f809a13_data_subject_requests.py"
)
_spec = spec_from_file_location("govoplan_data_subject_requests_migration", _path)
if _spec is None or _spec.loader is None:
raise RuntimeError(f"Unable to load migration implementation from {_path}")
_module = module_from_spec(_spec)
_spec.loader.exec_module(_module)
revision = _module.revision
down_revision = _module.down_revision
branch_labels = _module.branch_labels
depends_on = _module.depends_on
upgrade = _module.upgrade
downgrade = _module.downgrade
@@ -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
@@ -0,0 +1,23 @@
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"
/ "f25c9d3e7a01_first_admin_enrollment.py"
)
_spec = spec_from_file_location("govoplan_first_admin_enrollment_migration", _path)
if _spec is None or _spec.loader is None:
raise RuntimeError(f"Unable to load migration implementation from {_path}")
_module = module_from_spec(_spec)
_spec.loader.exec_module(_module)
revision = _module.revision
down_revision = _module.down_revision
branch_labels = _module.branch_labels
depends_on = _module.depends_on
upgrade = _module.upgrade
downgrade = _module.downgrade
+10 -1
View File
@@ -5,9 +5,18 @@ 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 first_admin as core_first_admin_models # noqa: F401 - populate core metadata
from govoplan_core.core import ownership as core_ownership_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.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,44 @@
"""adopt German as the untouched system reference locale
Revision ID: a36d8e4f9b12
Revises: f25c9d3e7a01
Create Date: 2026-08-05 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "a36d8e4f9b12"
down_revision = "f25c9d3e7a01"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
if "core_system_settings" not in set(sa.inspect(bind).get_table_names()):
return
settings = sa.table(
"core_system_settings",
sa.column("id", sa.String),
sa.column("default_locale", sa.String),
sa.column("created_at", sa.DateTime(timezone=True)),
sa.column("updated_at", sa.DateTime(timezone=True)),
)
bind.execute(
settings.update()
.where(settings.c.id == "global")
.where(settings.c.default_locale == "en")
.where(settings.c.created_at == settings.c.updated_at)
.values(default_locale="de", updated_at=sa.func.now())
)
def downgrade() -> None:
# Locale selection is user-visible state. A downgrade must not overwrite a
# German value that may have been selected explicitly after this migration.
pass
@@ -0,0 +1,77 @@
"""add governed data-subject request workflow
Revision ID: b47e6f809a13
Revises: a36d8e4f9b12
Create Date: 2026-08-07 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "b47e6f809a13"
down_revision = "a36d8e4f9b12"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "core_data_subject_requests" in inspector.get_table_names():
return
op.create_table(
"core_data_subject_requests",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("reference", sa.String(length=120), nullable=False),
sa.Column("request_kind", sa.String(length=30), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("subject", sa.JSON(), nullable=False),
sa.Column("purpose", sa.String(length=1000), nullable=False),
sa.Column("legal_basis", sa.String(length=1000), nullable=True),
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("requested_by_account_id", sa.String(length=36), nullable=False),
sa.Column("search_result", sa.JSON(), nullable=False),
sa.Column("erasure_plan", sa.JSON(), nullable=False),
sa.Column("execution_result", sa.JSON(), nullable=False),
sa.Column("coverage", sa.JSON(), nullable=False),
sa.Column("evidence_sha256", sa.String(length=64), nullable=True),
sa.Column("resource_revision", sa.Integer(), nullable=False),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
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_data_subject_requests")),
)
op.create_index(
op.f("ix_core_data_subject_requests_tenant_id"),
"core_data_subject_requests",
["tenant_id"],
unique=False,
)
op.create_index(
op.f("ix_core_data_subject_requests_status"),
"core_data_subject_requests",
["status"],
unique=False,
)
op.create_index(
op.f("ix_core_data_subject_requests_due_at"),
"core_data_subject_requests",
["due_at"],
unique=False,
)
op.create_index(
"ix_core_data_subject_requests_tenant_status",
"core_data_subject_requests",
["tenant_id", "status"],
unique=False,
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "core_data_subject_requests" in inspector.get_table_names():
op.drop_table("core_data_subject_requests")
@@ -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)
@@ -0,0 +1,110 @@
"""add controlled first-administrator enrollment evidence
Revision ID: f25c9d3e7a01
Revises: e14b8c2d6f90
Create Date: 2026-08-04 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "f25c9d3e7a01"
down_revision = "e14b8c2d6f90"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "core_first_admin_enrollments" not in tables:
op.create_table(
"core_first_admin_enrollments",
sa.Column("installation_id", sa.String(length=100), nullable=False),
sa.Column("state", sa.String(length=24), nullable=False),
sa.Column("generation", sa.Integer(), nullable=False),
sa.Column("token_sha256", sa.String(length=64), nullable=True),
sa.Column("token_fingerprint", sa.String(length=16), nullable=True),
sa.Column("issued_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("consumed_account_id", sa.String(length=36), nullable=True),
sa.Column("consumed_membership_id", sa.String(length=36), nullable=True),
sa.Column("consumed_tenant_id", sa.String(length=36), nullable=True),
sa.Column("consumed_email", sa.String(length=320), nullable=True),
sa.Column("consumed_display_name", sa.String(length=255), nullable=True),
sa.Column("consumed_request_sha256", sa.String(length=64), nullable=True),
sa.Column("issue_reason", sa.String(length=500), nullable=True),
sa.Column("event_count", sa.Integer(), nullable=False),
sa.Column("evidence_head_sha256", sa.String(length=64), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint(
"installation_id",
name=op.f("pk_core_first_admin_enrollments"),
),
)
op.create_index(
op.f("ix_core_first_admin_enrollments_state"),
"core_first_admin_enrollments",
["state"],
unique=False,
)
op.create_index(
op.f("ix_core_first_admin_enrollments_expires_at"),
"core_first_admin_enrollments",
["expires_at"],
unique=False,
)
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "core_first_admin_enrollment_events" not in tables:
op.create_table(
"core_first_admin_enrollment_events",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("installation_id", sa.String(length=100), nullable=False),
sa.Column("sequence", sa.Integer(), nullable=False),
sa.Column("event_type", sa.String(length=80), nullable=False),
sa.Column("generation", sa.Integer(), nullable=False),
sa.Column("evidence", sa.JSON(), nullable=False),
sa.Column("previous_sha256", sa.String(length=64), nullable=True),
sa.Column("event_sha256", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["installation_id"],
["core_first_admin_enrollments.installation_id"],
name=op.f(
"fk_core_first_admin_enrollment_events_installation_id_core_first_admin_enrollments"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_core_first_admin_enrollment_events"),
),
sa.UniqueConstraint(
"installation_id",
"sequence",
name="uq_core_first_admin_enrollment_event_sequence",
),
)
for column in ("installation_id", "event_type", "event_sha256"):
op.create_index(
op.f(f"ix_core_first_admin_enrollment_events_{column}"),
"core_first_admin_enrollment_events",
[column],
unique=False,
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "core_first_admin_enrollment_events" in tables:
op.drop_table("core_first_admin_enrollment_events")
if "core_first_admin_enrollments" in tables:
op.drop_table("core_first_admin_enrollments")
+47 -6
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:
@@ -43,6 +47,9 @@ Recommended fields:
irreversible
- expected effects
- idempotency key strategy
- recovery mode: atomic, compensating, snapshot restore, forward recovery, or
irreversible
- concrete verification steps which prove whether the effect occurred
- audit event names
- preview provider
@@ -85,16 +92,45 @@ The runner should execute an action plan as follows:
4. Run permission and policy checks.
5. Generate a consequence preview.
6. Reserve or verify the idempotency key.
7. Execute the owning module capability.
8. Record observed effects.
9. Emit events and audit records.
10. Mark the command complete, retryable, quarantined, or requiring manual
7. Create a durable recovery operation and acquire its execution fence.
8. Persist dispatch evidence before a non-atomic provider call.
9. Execute the owning module capability.
10. Verify the provider result and every announced effect using the action's
declared recovery checks.
11. Commit the local projection and verified recovery checkpoint together.
12. Emit events and audit records.
13. Mark the command complete, retryable, quarantined, or requiring manual
intervention.
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
an ordinary process failure or a fresh command. The runner records an unknown
outcome, releases its execution authority, and blocks continuation until an
operator or provider reconciliation proves either that the effect occurred or
that it is absent.
`ActionDefinition.recovery_mode` and `recovery_verification` are part of the
provider contract. The default is conservative forward recovery with explicit
provider-result and effect verification. Atomic mode is valid only when the
provider effect and its local projection share the same database transaction.
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 +146,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.
+44
View File
@@ -0,0 +1,44 @@
# 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. |
| WebUI `react-router-dom` build alias | Resolves tagged `0.1.x` module source imports to Core's single `react-router` runtime so one composition never loads two router contexts. | Tagged `0.1.x` WebUI source window. | Remove at `0.2` after every supported module tag imports `react-router` directly. |
## 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.
+90 -1
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;
@@ -120,9 +157,48 @@ The initial implementation includes provider-neutral orchestration helpers:
- `apply_configuration_package(...)`
- `export_configuration_package(...)`
Portable fragments may bind deployment-specific operator input without placing
that value in the signed reusable definition. A payload value of
`{"$data": "requirement_key"}` references a key declared in the manifest's
`data_requirements`. Preflight fails before invoking the owning provider when a
reference is malformed, undeclared, or unresolved. Once supplied, Core replaces
the reference in memory and passes only the resolved fragment to the provider.
This mechanism is for deployment bindings and wording, not plaintext secrets:
credential-envelope or environment references remain the normal portable
boundary.
The first concrete provider is `govoplan_access.backend.configuration_provider`.
It supports access-owned `roles`, `groups`, and `group_role_assignments`
fragments and applies them idempotently.
fragments and applies them idempotently. Mail and Files also register providers
for deployment configuration: Mail owns receipt-bound SMTP profiles and Files
validates the deployment-owned managed-storage binding.
### Deployment capability receipt
The installer mounts a bounded, non-secret infrastructure receipt at the path
named by `GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH`. Core validates that document
once for configuration-package context and exposes typed capability and
post-install-task records to providers. Invalid receipts fail closed. Endpoint
metadata is sanitized, and secret fields may cross this boundary only as
`env:VARIABLE_NAME` references.
Feature providers remain responsible for their own semantics:
- Mail can derive host and port from `mail.smtp`, collect missing non-secret
transport fields, and bind an existing credential-envelope id. It never
accepts or exports a username, password, token, or decrypted credential.
- Files compares `files.storage` with the effective runtime backend, endpoint,
trust marker, bucket, and presence of referenced environment secrets. Storage
remains deployment-owned, so the provider reports `skip` when they agree and
blocks drift instead of rewriting process environment or storage credentials.
- A system-scoped Mail profile requires system configuration authority. Tenant
scope is the conservative default.
- Existing Mail configuration is preserved unless a reviewed fragment
explicitly selects `on_conflict: update`. Reapplying an unchanged fragment is
a no-op.
Ops projects the same Core-validated receipt. It must not maintain a second
parser with different validation or secret-handling rules.
The admin wizard backend starts with these routes:
@@ -146,6 +222,14 @@ The admin wizard backend starts with these routes:
10. Store import provenance, package version, supplied non-secret metadata, and
audit events.
Provider applies may commit independently. Core therefore stops at the first
apply or health blocker and reports an explicit rollback state. A blocked
preflight or a no-op needs no recovery; a successful multi-provider mutation
retains the reviewed pre-apply database snapshot as its generic rollback path;
a later-provider failure is reported as a partial apply that requires snapshot
recovery or an explicitly supported module-owned compensation. The generic
wizard never claims atomic cross-module undo.
The wizard should display everything necessary and nothing unnecessary. Generic
sections should cover package trust, dependency plan, required data, conflicts,
review, and result. Module-specific fields should appear only when the selected
@@ -196,6 +280,11 @@ Exported packages should record provenance: source GovOPlaN version, module
versions, exporter identity, timestamp, selected scope, redactions, and
validation status.
The orchestrator emits this provenance independently of provider payloads and
lists secret requirement keys as redacted without serializing their supplied
values. Providers still own the deeper rule that credentials, tokens, and
decrypted envelope contents must never appear in exported fragments.
## Catalogs And Trust
Configuration catalogs should follow the existing module package catalog model:
+73
View File
@@ -0,0 +1,73 @@
# Contextual Help Contract
GovOPlaN exposes context-sensitive help through `F1` and the titlebar help
control. The shell resolves a stable help identity from the focused control,
its containing surface, and the current route. The Docs module then projects
the best visible user or administrator topic for that identity.
## Resolution Order
The WebUI resolves help in this order:
1. an explicit `helpContextId` or `data-help-context-id` on the focused item
2. the focused shared control's `interfaceId`, `helpTopicId`, and label key
3. a containing dialog, card, administration section, or page surface
4. the current registered route, including dynamic module routes
5. a stable route-derived fallback when no explicit identity is available
Focused field and action contexts retain the page context as
`fallback_context`. This lets Docs show a field-specific topic when one exists
and otherwise open the owning page or module documentation instead of a generic
help page.
## Documentation Lookup
Static `DocumentationTopic` contributions announce exact contexts through
`metadata.help_contexts`. Core publishes that catalogue with the enabled module
manifest, allowing the shell to link directly to an exact topic when possible.
Docs still performs the authoritative audience, permission, configured-state,
and documentation-type filtering.
Core also maps explicit route, navigation, settings, and View surface IDs to
the module's static user or administrator documentation baseline. This makes a
page association complete by default and gives every derived field/action
context a useful fallback. Exact `metadata.help_contexts` remain the preferred
authoring mechanism for consequential or unfamiliar controls.
When there is no exact topic, Docs resolves the page fallback and then the first
visible topic owned by the module. If Docs is unavailable, the shell opens the
hosted documentation with the same context parameters.
## Authoring Controls
Core shared controls expose stable help metadata. Prefer these props rather
than adding custom `F1` listeners:
- `interfaceId` identifies a durable UI surface or action.
- `helpContextId` identifies a documentation context when it differs from the
interface identity.
- `helpModuleId` identifies the documentation-owning module when a shared
control is embedded in another module's page.
- `helpTopicId` links directly to a module-owned documentation topic.
- translated label keys provide deterministic field identities for ordinary
`FormField`, `ToggleSwitch`, search, date/time, email, button, dialog, and card
controls.
- `TableActionGroup` action definitions carry the same identities so focused
row actions can resolve consequence-specific help.
- `PageLayout` owns the page help scope and documentation identity for ordinary
headed pages. `WorkspaceLayout` owns the full-canvas workspace scope and its
labelled primary/content panes; pages inside it use `PageLayout` in
`workspace` mode and retain their own route-level help identity.
Module routes, public routes, settings sections, and administration sections
may also declare `helpContextId` and `helpTopicId`. Each module must keep a
static user/admin documentation baseline and should list its important route,
workflow, setting, permission, and limitation identities in
`metadata.help_contexts`.
## Boundary
Help identities describe presentation context; they are not authorization
claims. Opening help never bypasses route or documentation permissions. Docs
owns documentation projection, feature modules own their content, and Core owns
focus capture, context resolution, and fallback routing.
+74
View File
@@ -0,0 +1,74 @@
# 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,
together with the container width at which the user selected it.
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 at that same container width never shrinks intentional user
overflow, so there is no drag-end snap. If the surrounding layout later
contracts, persisted tracks may shrink toward their hard minima. The layout
retains only the amount of horizontal overflow deliberately created by the
user; an exact-cover layout therefore remains exact-cover at narrower widths.
Legacy snapshots from the former hard-pixel persistence contract are discarded
once and recomputed from the declared column layout.
## 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;
- responsive contraction of persisted layouts without losing deliberate overflow;
- 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.
+91
View File
@@ -0,0 +1,91 @@
# Data-Subject Request Contract
This document defines the provider-neutral workflow for access and erasure
requests. It is an operational control and evidence mechanism. It does not
replace legal review, identity verification, retention policy, or the
institution's statutory response process.
## Ownership
Core owns the request aggregate, lifecycle API, optimistic concurrency,
provider discovery, export manifest, execution orchestration, and audit event
names. Modules that store subject-related data own their search, explanation,
retention, and mutation behavior through a `privacy.dsar.<module>` capability.
Core never scans module tables or guesses how a foreign resource may be
erased.
Access owns the first provider. It finds tenant memberships plus safe account,
identity, assignment, API-key, and session metadata. It does not export secret
hashes, session tokens, IP addresses, or browser fingerprints. Tenant-local
membership data can be anonymized and authentication material can be revoked.
Global accounts and identities require manual system-level review because they
may serve more than one tenant.
## Lifecycle
1. A privacy officer records a verified selector, purpose, legal basis, due
date, and internal reference.
2. Search invokes every available tenant capability independently. A provider
failure is isolated and recorded; it cannot turn an incomplete search into
a successful one.
3. The JSON export contains the request, records, provider runs, coverage,
retention reasons, execution evidence, and a SHA-256 manifest digest.
4. An erasure request produces stable provider-owned actions. Immutable
evidence generates an explicit non-executable `retain` decision.
5. Execution accepts only selected executable actions from the current plan.
It requires `If-Match`, the current resource revision, the dedicated erase
permission, and the exact `ERASE <request-id>` confirmation phrase.
6. Provider execution is idempotent. Completed or unchanged effects remain
durable in the request's execution evidence.
The API is rooted at
`/api/v1/admin/privacy/data-subject-requests`. Access exposes the independent
permissions `access:privacy:read`, `access:privacy:manage`,
`access:privacy:export`, and `access:privacy:erase`; the built-in privacy
officer role contains all four.
## Provider Rules
A provider must:
- enforce tenant ownership for every record and action;
- return stable, unique resource and action identities;
- avoid credentials, hashes, tokens, unnecessary telemetry, and unrelated
third-party data;
- distinguish mutable personal data from immutable institutional evidence;
- state a retention reason for immutable evidence;
- propose manual review instead of an automatic action when authority is
ambiguous or a resource spans tenants;
- return exactly one execution result per requested action;
- make execution idempotent and avoid committing the caller's transaction;
- keep all actual mutations inside the owning module.
Each active module without a DSAR provider is listed in coverage. This is a
deliberate fail-visible state, not proof that the module stores personal data.
An institution may call an export complete only after it has reviewed both the
provider runs and that coverage list.
## Retention And Evidence
Erasure and retention are separate decisions. Stable object IDs, authorization
history, function incumbency, formal decisions, delivery evidence, and audit
records may remain necessary for accountability. Providers expose those items
with a concrete reason and Core prevents them from being selected as executable
actions. Policy may further restrict an action, but it must never silently
loosen a provider's retention decision.
All lifecycle mutations and exports produce tenant audit events. The request
stores an evidence digest after every revision. This digest detects accidental
or unauthorized mutation of the aggregate; it is not a digital signature or a
substitute for signed recovery evidence.
## Current Limits
- Access is the first native provider. Other enabled modules appear in the
coverage list until they add a provider or an explicit no-subject-data
declaration is standardized.
- Verification of the requester's identity and statutory deadline escalation
remain institutional workflows outside this API.
- Global account or identity erasure is deliberately manual.
- Exports are JSON. A human-readable signed response package remains a later
Reporting/Templates integration.
+89 -15
View File
@@ -7,6 +7,16 @@ files.
## Runtime Configuration Contract
Worker and queue observability is provider-neutral. Runtime modules register a
bounded `RuntimeWorkStatusProviderRegistration` with Core; the Ops module
projects its sanitized status without importing Celery, Redis, or module job
implementations. Providers must use explicit `null` values for unsupported
queue depth, active/reserved work, failure count, and heartbeat evidence. An
unavailable metric must never be interpreted as zero or as proof of health.
The standard Core adapter reports the configured Celery/Redis runtime and
combines its bounded inspection result with registered worker heartbeat and
stale-threshold evidence.
Self-hosted installability follows the staged approach documented in
`SELF_HOSTED_INSTALLABILITY.md`: generate an explicit env template, validate it,
run production-like rehearsal with Compose-backed dependencies, then use the
@@ -36,7 +46,7 @@ set +a
| Setting | Required outside dev | Purpose |
| --- | --- | --- |
| `APP_ENV` | yes | Runtime profile. Use `prod`, `staging`, or a deployment-specific value outside local development. |
| `MASTER_KEY_B64` | yes | Fernet key or base64 encoded 32-byte key used for encrypted module secrets. Rotate through an explicit operator plan. |
| `MASTER_KEY_B64` | yes | Fernet key or base64 encoded 32-byte deployment root used for encrypted module secrets and, when enabled, the Encryption module's local server-envelope provider. Rotate only through an explicit provider-aware migration plan. |
| `DATABASE_URL` | yes | SQLAlchemy database URL for core and installed modules. SQLite is supported for dev/small installs; PostgreSQL is the preferred production target. |
| `ENABLED_MODULES` | yes | Comma-separated startup module set. Keep `tenancy,access` enabled; keep `admin` enabled for operator UI. |
@@ -57,6 +67,8 @@ PY
| `GOVOPLAN_MIGRATION_TRACK` | `release` | Use the release track for normal runtime and deployments. Use `dev` only for fresh/disposable databases that intentionally replay detailed development migrations. |
| `DEV_AUTO_MIGRATE_ENABLED` | `true` | Dev convenience only. Production should run migration commands explicitly during deployment. |
| `DEV_BOOTSTRAP_ENABLED` | `false` | Dev bootstrap only. `govoplan_core.devserver` and `govoplan/tools/launch/launch-dev.sh` default it to `true`; use controlled first-admin creation outside dev. |
| `FIRST_ADMIN_ENROLLMENT_TTL_SECONDS` | `1800` | Lifetime of a locally issued production enrollment credential. Allowed range: 60 seconds to 24 hours. |
| `FIRST_ADMIN_ENROLLMENT_FILE` | `/run/govoplan/first-admin-enrollment.json` | Local operator artifact. The command creates it with mode `0600` and never prints the secret. |
Operator rule: take a database backup before applying migrations or destructive
module retirement. For non-SQLite databases, configure deployment-specific
@@ -144,8 +156,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 +169,16 @@ 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. |
| `CELERY_VISIBILITY_TIMEOUT_SECONDS` | `3600` | Maximum time before Redis may redeliver work left unacknowledged by a lost worker. Set this above the longest supported task duration; changing it requires a worker-loss acceptance drill. |
| `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
```
@@ -171,6 +190,13 @@ crashes, and expired worker leases:
python -m celery -A govoplan_core.celery_app:celery beat --loglevel INFO
```
Before promoting a worker composition, run the repository worker-runtime drill
against the same Redis and Core build. It uses the bounded
`govoplan.worker.acceptance` task and records publish/consume, retry, warm
SIGTERM, and worker-loss redelivery evidence without accessing tenant data.
Production evidence must use the deployed queue configuration and a visibility
timeout that is longer than every supported business task.
### Storage
| Setting | Default | Notes |
@@ -183,6 +209,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_*`.
@@ -205,9 +236,13 @@ prefer `FILE_STORAGE_*`.
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; a bounded process-local
fallback keeps development and Redis outages functional, with per-process
enforcement until Redis recovers.
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
@@ -253,12 +288,15 @@ through the same trusted address range.
| --- | --- |
| `GOVOPLAN_MODULE_PACKAGE_CATALOG_URL` or `GOVOPLAN_MODULE_PACKAGE_CATALOG` | Module package catalog source. |
| `GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE` | Preferred production keyring path. |
| `GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL` | Approved catalog channel, for example `stable`. |
| `GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS` | Comma-separated approved catalog channels, for example `stable`. The legacy singular name remains readable during migration. |
| `GOVOPLAN_LICENSE_TRUSTED_KEYS_FILE` | Trusted license issuer keyring path. |
| `GOVOPLAN_LICENSE_ENFORCEMENT` | Enables license enforcement when set to `true`. |
Trust roots are deployment-managed and should not be editable through the
running WebUI.
running WebUI. When no catalog override is configured, the Admin package
directory uses GovOPlaN's public stable catalog and the trust anchor bundled
with the installed Core release. Production operators may still pin a newer or
institution-specific catalog/keyring explicitly with the settings above.
### Mail Test Credentials
@@ -277,8 +315,34 @@ configuration, not the core runtime contract. Store them in a local ignored
3. Build the WebUI from `webui/package.release.json` or deploy a prebuilt
artifact from the same release tag.
4. Run database migrations with the target `DATABASE_URL`.
5. Create the first tenant and system owner through the controlled bootstrap or
one-time admin command for the deployment.
5. Create the first tenant and system owner through the controlled bootstrap:
```bash
python -m govoplan_core.commands.first_admin status
python -m govoplan_core.commands.first_admin issue \
--reason "initial production installation"
```
The issue command fails when an active system administrator already exists,
writes the random credential only to `FIRST_ADMIN_ENROLLMENT_FILE`, and does
not print it. Check `GET /api/v1/bootstrap/status`, then submit the account
and initial tenant fields to `POST /api/v1/bootstrap/first-admin` with the
secret in `X-GovOPlaN-Enrollment-Token`. The operation creates the protected
system owner and initial tenant-owner membership in one transaction and
retires the credential. A repeated identical request returns the same result
without creating another owner.
If the artifact is lost or expires before use, a local operator may rotate
it only while no durable system administrator exists:
```bash
python -m govoplan_core.commands.first_admin recover \
--reason "expired installation handoff"
```
Issue and recovery write hash-chained Core evidence and an audit event. They
never enable or reuse `DEV_BOOTSTRAP_ENABLED`, `DEV_BOOTSTRAP_PASSWORD`, or
`DEV_BOOTSTRAP_API_KEY`.
6. Start the API service with `govoplan_core.server.app:app`.
7. Start workers when `CELERY_ENABLED=true`.
8. Start the WebUI/reverse proxy and verify CORS/cookie settings.
@@ -328,7 +392,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`
@@ -408,6 +472,14 @@ SQLite's backup API; non-SQLite databases require
`--database-backup-command`, `--database-restore-check-command`, and
`--database-restore-command`.
Every non-dry run also owns the database-fenced
`core:module-lifecycle:deployment` recovery operation. The run record includes
its operation id and status. A supervised run reaches durable `succeeded` only
after restart and health verification. `recovery_required` or `outcome_unknown`
blocks another lifecycle mutation until the recorded operation is reconciled;
do not bypass this by deleting `install.lock`. See
[`MODULE_LIFECYCLE_RECOVERY.md`](MODULE_LIFECYCLE_RECOVERY.md).
Database hook commands receive:
- `GOVOPLAN_INSTALLER_RUN_DIR`
@@ -447,7 +519,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
@@ -468,7 +542,7 @@ checks, catalog trust, signing, keyring, replay, and license operation.
## Operator Checklist
- Runtime secrets are injected outside git.
- `MASTER_KEY_B64` is set and backed up securely.
- `MASTER_KEY_B64` is set and backed up securely; restores of locally encrypted content fail closed without the exact matching key.
- Database backup and restore commands are tested.
- File/object storage is durable and backed up.
- `CORS_ORIGINS` and cookie settings match the deployed WebUI origin.
+18
View File
@@ -9,12 +9,25 @@ 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. |
| Provider-neutral record filing | `RECORDS_FILING_CONTRACT.md` | Exact source-revision identity, current source authorization, idempotent filing, capability discovery, and ownership boundary. |
| Ticket routing and Case escalation | `TICKET_INTEGRATION_CONTRACTS.md` | Optional fail-open routing, replay-safe Case handoff, authorization, evidence, and ownership boundaries. |
| Temporal data read context | `TEMPORAL_DATA_CONTEXT.md` | Valid-time and recorded-time titlebar selection, HTTP/cache contract, security boundary, and module-adoption rule. |
| Cross-module information governance adoption | `INFORMATION_GOVERNANCE_ADOPTION.md` | Manifest evidence and enforcement rules for temporal browsing, purpose-aware access, retention, and institutional context. |
| Data-subject access and erasure requests | `DATA_SUBJECT_REQUESTS.md` | Provider-owned search and mutation, explicit coverage, governed export, retained evidence, permissions, and idempotent execution. |
| Context-sensitive F1 help | `CONTEXTUAL_HELP_CONTRACT.md` | Focus, route, module-manifest documentation contexts, Docs projection, and hosted fallback. |
| Semantic documentation subjects | `SEMANTIC_DOCUMENTATION_SUBJECTS.md` | Stable configured-artifact identity, safe provider discovery, revision review, authorization, and lifecycle semantics. |
| German localization and help quality gate | `LOCALIZATION_AND_HELP_QUALITY.md` | German reference locale, new-installation default, catalog completeness, automatic page associations, and explicit-help review priorities. |
| 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. |
| Module lifecycle recovery | `MODULE_LIFECYCLE_RECOVERY.md` | Installer/live-graph recovery modes, deployment fence, evidence, retry blocking, and operator reconciliation. |
## Release And Operations
@@ -25,13 +38,18 @@ 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. |
| Stable platform ideas | `govoplan/docs/strategy/PLATFORM_CORE_IDEAS.md` | Cross-product thesis, canonical distinctions, product experience rule, maturity rule, and decision test. |
| Current cross-product reconciliation | `govoplan/docs/strategy/STRATEGY_STATUS.md` | The only current prose status source; generated evidence and Gitea remain authoritative inputs. |
| Institutional governance target | `govoplan/docs/architecture/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. |
| Core interface migration | `INTERFACE_PATTERN_MIGRATION.md` | Core-owned settings, credential, retention, lifecycle, and shared-component evidence for the product pattern language. |
| 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`. |
| Configuration packages | `CONFIGURATION_PACKAGES.md` | Package model, provider contract, import/export flow, and tracking slices. |
+44
View File
@@ -0,0 +1,44 @@
# Durable Recovery Operations
Modules must use `begin_durable_recovery_operation` for work whose effects can
outlive the caller's SQLAlchemy transaction. The helper commits the canonical
request hash, recovery plan, precondition evidence, running state, and lease
fence before the caller mutates object storage, a queue, a filesystem, or an
external provider.
Each later checkpoint is written through an independent database session. A
business-transaction rollback therefore cannot erase evidence of an earlier
effect. Successful completion requires concrete verification checks and a valid
hash chain. Compensation likewise records recovery-required, recovering, and
verified-recovered checkpoints rather than reporting an ordinary failure.
A definitive pre-effect or provider rejection records terminal `rejected`
evidence instead of being mislabeled as success, atomic rollback, or recovery
work.
If a runtime disappears, another runtime may claim the operation only after the
lease expires. The takeover records both fences. A stale compensatable operation
becomes recovery-required; a stale forward-only or irreversible external effect
becomes outcome-unknown; a database-only atomic operation is recorded failed
because its transaction rolled back. Takeover never re-executes the original
request automatically.
Evidence and metadata may contain opaque references, digests, counts, and
provider result codes. They must never contain credentials or resolved secrets.
Ops is the platform surface for unresolved operation status; owning modules must
provide the reconciliation action and business-level explanation.
Database-only operations must use the durable handle's atomic terminal methods
when their module rows and final recovery checkpoint belong to one invariant.
Those methods stage the terminal checkpoint and lease release in the caller's
SQLAlchemy transaction, then commit the domain rows and recovery evidence
together. A failed commit rolls both back and leaves the previously durable
`running` record available for stale-fence handling; modules must not commit
their domain state first and close an `atomic` recovery record afterwards.
An owning module may reconcile an `outcome_unknown` provider effect through the
claimed durable handle's `resolve_unknown` method. External evidence that the
effect occurred records verified success. Evidence that it did not occur moves
the operation through recovery-required and recovering to verified recovered,
so any later attempt must use a new deliberate idempotency key. The method does
not infer provider state and requires the same terminal verification structure
and hash-chain checks as ordinary completion.
+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/architecture/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`.
+1 -1
View File
@@ -13,4 +13,4 @@ tools/gitea/gitea-sync-wiki.py --help
Canonical documentation:
- `/mnt/DATA/git/govoplan/docs/GITEA_ISSUES.md`
- `/mnt/DATA/git/govoplan/docs/project/GITEA_ISSUES.md`
+2 -1
View File
@@ -221,7 +221,8 @@ Admin lists use bounded container grids:
- recipient import with column mapping;
- session/device revocation UI;
- backup/restore, monitoring, and update procedures;
- DSAR workflows and evidence bundle verifier;
- additional module providers and signed human-readable response packages for
the implemented DSAR workflow described in `DATA_SUBJECT_REQUESTS.md`;
- campaign ownership transfer workflow;
- policy impact analysis before delete/disable/unshare/change;
- LDAP/OIDC/SAML provisioning;
+110 -95
View File
@@ -10,12 +10,15 @@ 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)
[GovOPlaN Roadmap](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/strategy/ROADMAP.md)
describes the corresponding cross-product stakeholder visions, configurable
service and operating configurations, connected outcome stories, and
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/add-ideas/govoplan/src/branch/main/docs/REFERENCE_JOURNEY_PROGRAM.md).
[Reference Journey Program](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/strategy/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/architecture/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
Those product documents are canonical; this Core roadmap remains their
technical sequencing and module-routing companion.
@@ -64,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`.
@@ -144,8 +148,9 @@ 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` |
@@ -153,13 +158,18 @@ pattern exists.
| 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` |
| 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
@@ -204,8 +214,9 @@ an editor applies a high-impact configuration change.
## Reference Journeys
The active sequence is selected. Workflow remains deliberately deferred and is
not a dependency of these 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: Campaign Demonstration Composition
@@ -256,9 +267,11 @@ 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.
Reporting consumes the product. Create `govoplan-datasources` or
`govoplan-dataflow` only after the concrete path proves repeated ownership that
does not belong to connectors, Reporting, or the producing domain module.
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.
### Journey 5: Collaborative Document Lifecycle
@@ -336,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
@@ -412,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.
@@ -526,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
@@ -591,23 +608,25 @@ in the capability waves:
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 before generalizing data-source or dataflow ownership.
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. Resume Workflow only by explicit product decision and constrain it with
stable actions from one demonstrated package.
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
- an unbounded general-purpose dataflow platform; the bounded governed BI
reference journey is selected
- 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
@@ -625,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
@@ -705,7 +720,7 @@ Release composition and tag-only repository handling are documented in
## Next Practical Work
The active cross-product story is
[`add-ideas/govoplan#14`](https://git.add-ideas.de/add-ideas/govoplan/issues/14).
[`GovOPlaN/govoplan#14`](https://git.add-ideas.de/GovOPlaN/govoplan/issues/14).
Module repositories own implementation issues; do not clone their state here.
Immediate issue buckets:
+131
View File
@@ -0,0 +1,131 @@
# Information Governance Adoption
## Platform Rule
Temporal browsing, purpose-aware access, retention, and institutional context
are platform-wide information-governance dimensions. Every module receives the
same contract by default. A module may claim `partial` or `enforced` only with
repository-owned object scope, evidence, and limitations; it may claim
`not_applicable` only when the dimension genuinely does not apply.
Historical business data is always authorized under the current security
state. No module may use a historical permission, membership, role, function
assignment, or policy projection to weaken present-day access.
Platform-wide adoption is tracked in
[GovOPlaN #40](https://git.add-ideas.de/GovOPlaN/govoplan/issues/40), with
temporal reads detailed in
[GovOPlaN #39](https://git.add-ideas.de/GovOPlaN/govoplan/issues/39).
## Manifest Declaration
`ModuleManifest.information_governance` publishes four dimensions:
- `temporal_browsing`;
- `purpose_aware_access`;
- `retention`;
- `institutional_context`.
Each dimension declares:
- adoption: `not_applicable`, `contract_only`, `partial`, or `enforced`;
- object types covered;
- repository-local test/documentation evidence;
- the remaining limitation for `contract_only` or `partial`.
The default is intentionally `contract_only`. It applies the platform rule
without pretending that existing domain queries and effects already enforce
it. `reference_ready`, `supported`, and `lts` modules cannot retain an
applicable dimension below `enforced`.
## Read Contract
For every persistent domain object, the owner classifies the read:
1. **Current-only:** historical semantics do not exist and the API says so.
2. **Valid-time:** select facts effective now or at the requested instant.
3. **Bitemporal:** additionally select only revisions known by `recorded_at`.
4. **All-validity:** return effective revisions in a bounded history view.
The Core temporal middleware supplies the request context. Owners apply it in
repositories or query helpers, include it in cache keys, return evaluated
context, and test current/at/all plus recorded-time boundaries. Search,
reporting, exports, selectors, counts, and drill-through must use the same
projection as the owning list/detail API.
## Purpose-Aware Access Contract
Permission establishes a technical action ceiling. Purpose-aware access asks
whether this actor, represented capacity, case/work item, legal basis, and
declared use may access this object now.
- A client-supplied purpose is an assertion, never authority by itself.
- The owner or Policy capability validates the purpose and returns explainable
provenance.
- Sensitive access can require case assignment, mandate, reason capture,
approval, or break-glass evidence.
- Search, selectors, reporting, exports, background jobs, and connectors apply
the same decision.
- Audit records the validated purpose identifier and decision reference, not
unnecessary content.
## Retention Contract
Every persistent object declares an owner, retention class or policy reference,
trigger, start instant, hold behavior, review/disposition action, and evidence.
Retention is not a generic timestamp deletion job.
- Domain owners enumerate and execute their own effects through a typed
retention provider.
- Policy resolves inherited ceilings and simulation.
- Records owns record disposition; Files owns byte/object effects; Audit owns
audit-detail behavior; external providers declare their own effect and
recovery semantics.
- Dry-run, legal hold, exact revision, idempotency, outcome unknown,
reconciliation, correction, and destruction evidence are mandatory for
consequential removal.
## Institutional Context Contract
Consequential objects and effects carry the relevant tenant, institution,
organization unit, function, mandate/jurisdiction, service/case/work item,
party/representation, decision, and record references. Context is minimized to
what the operation needs. Organizational membership is not itself permission
or mandate.
Events, automation intents, audit evidence, records, and external effects retain
the same governed context envelope or an exact reference to it. Consumers must
not reconstruct authority later from mutable current structures.
## Adoption Order
1. Inventory every domain list/detail/search/export/effect and classify all
four dimensions.
2. Migrate institutional owners first: Access, IDM, Organizations, Mandates,
Services, Parties, Cases, Approvals, Committee, Decisions, Voting, and
Records.
3. Migrate communication and content: Addresses, Distribution Lists, Campaign,
Postbox, Mail, Calendar, Files, Templates, and Forms Runtime.
4. Migrate data projections: Connectors, Datasources, Dataflow, Reporting,
Search, Risk Compliance, and Dashboard.
5. Migrate workflow/task/background/provider operations and prove that no
asynchronous path drops context.
6. Advance manifest claims only after owner tests and browser/reference-journey
evidence pass.
The generated platform inventory reports adoption counts and module details.
Gitea tracks individual migrations; the declaration is evidence and a maturity
gate, not a substitute for implementation.
## Definition Of Enforced
A dimension is `enforced` only when:
- all declared object types and public reads/effects use it;
- list/detail/count/search/export/worker behavior is consistent;
- cache and pagination semantics cannot cross contexts;
- absence, invalid values, and inaccessible referenced context fail safely;
- tests cover current, historical, unauthorized, replay, and module-absence
combinations appropriate to the dimension;
- user/admin documentation explains behavior and limitations;
- the manifest cites those tests and docs.
+162
View File
@@ -0,0 +1,162 @@
# 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.
Interactive reads use the separate platform temporal-data context documented in
`TEMPORAL_DATA_CONTEXT.md`; it never changes current authorization or supplies
mutation dates.
## 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.
+53
View File
@@ -0,0 +1,53 @@
# Core Interface Pattern Migration
This document records the Core-owned part of the product-wide interface
pattern-language rollout. The normative product grammar and complete route
inventory live in the `govoplan` meta repository. Core owns reusable behavior;
domain modules own their compositions.
## Core Surfaces
| Surface | Pattern | Consequence and provenance contract | Evidence |
| --- | --- | --- | --- |
| User settings | Two-zone settings workspace with typed controls and unsaved-change protection | Save actions distinguish busy, unchanged, and test-in-progress states; contextual help resolves through Docs or the hosted fallback | `SettingsPage.tsx`, `test-core-interface-patterns.mjs` |
| Reusable credentials | Repeated administration with an adaptive create/edit dialog, optional password generator, and destructive confirmation | Secret values are write-only; generated candidates use the browser cryptographic API without a weak fallback and do not replace the field until explicitly confirmed; scope/permission blockers name the required action, responsible actor, and destination; unavailable row actions remain keyboard-explainable | `CredentialEnvelopeManager.tsx`, shared `PasswordField`, `PasswordGeneratorDialog`, `ActionBlockerHint`, `Button`, `TableActionGroup`, and `ConfirmDialog` |
| Retention policy | Effective-policy editor with inherited source paths and typed, narrowing-only controls | Parent locks and missing write authority are explicit; the save action distinguishes locks, missing target, loading, clean draft, and active save | `RetentionPolicyManagement.tsx`, policy logic tests, `test-core-interface-patterns.mjs` |
| Module lifecycle | Guided operator projection over durable installer-queue evidence | Preflight, handoff, progress, stale evidence, recovery, and rollback consequences remain visible | Admin module lifecycle tests and the Core installer-queue contract |
| Shared page frame | Domain-neutral headed page layout used by Core and optional modules | Standalone, workspace, and embedded modes make inset and scroll ownership explicit; sticky heading, rich descriptions, route actions, notices, loading, narrow-layout collapse, and contextual-help identity are centralized; composite administration workspaces may delegate the visible heading to their contributed panel while retaining the same frame; `AdminPageLayout` composes the contract | `PageLayout.tsx`, `page-layout.test.tsx`, Core Settings, Access administration, Docs, Mail bounce processing, Dashboard, Ops, Campaign, and `check-shared-webui-layouts.py` |
| Full-canvas workspace | Navigation/content and list/detail canvases that own pane geometry and scrolling | Navigation and split variants, primary-pane width, pane-owned or contained scrolling, responsive stacking or navigation collapse, pane labels, and contextual-help identity are centralized without encoding domain navigation | `WorkspaceLayout.tsx`, `workspace-layout.test.tsx`, Core Settings, Access administration, Docs, Organizations, Campaign, Templates, Approvals, and `check-shared-webui-layouts.py`; the raw-workspace exception baseline is empty |
| Full-height module frame | Outer module landmark and viewport/container sizing | `WorkspaceFrame` centralizes surface, overflow, box sizing, accessible naming, help identity, and application-viewport height so modules do not copy the `100vh - shell` frame | `WorkspaceFrame.tsx`, `layout-primitives.test.tsx`, Dataflow, Workflow, Datasources, Distribution Lists, Notifications, Tasks, Scheduling, Forms, Portal, Projects, Records, and Reporting |
| Responsive action toolbar | Domain-neutral action and filter grouping for pages, workspaces, editors, and overlays | Density, surface, grouping, flexible space, accessible naming, toolbar help identity, and responsive wrapping are centralized while modules retain action wording, authority, and consequence | `ActionToolbar.tsx`, `layout-primitives.test.tsx`, WYSIWYG, Calendar, Files, Forms, Templates, and the product-wide primitive check |
| Semantic page and pane action bars | Overview, collection, detail, editor, and workspace intent declared independently from frame geometry; full-canvas panes add workspace/collection/detail/editor scope | Core renders leading Reload from a guarded descriptor; editor persistence owns clean, dirty, invalid, saving, failed, and conflict feedback plus guarded Discard and far-right Save; destructive actions occupy an explicit named boundary; read-only surfaces do not invent Save | `PageActionBar.tsx`, `WorkspaceActionBar.tsx`, `PAGE_LAYOUT_USAGE_GUIDELINES.md`, component and browser conformance, every headed page and full-canvas workspace, and the discovery-based `check-shared-webui-layouts.py` |
| Catalogue and state composition | Search/filter bars, selectable navigation lists, count badges, and empty/blocked/error panels | Width, surface, wrap, selection geometry, title/description truncation, numeric emphasis, state sizing, tone and action placement are centralized; modules retain query behavior, object state and consequences | `FilterBar.tsx`, `SelectionList.tsx`, `CountBadge.tsx`, `StatePanel.tsx`, `layout-primitives.test.tsx`, and list/detail modules across Cases, Committee, Dataflow, Forms, Notifications, Portal, Postbox, Projects, Records, Reporting, Tasks, Templates, and Workflow |
| Content and form grids | Equal-column content, field, and native-form geometry | Explicit 14 columns, standard gaps, item spans, alignment, and named narrow/workspace/standard/wide collapse points replace generic and module-prefixed copies; unequal domain tracks remain local | `ContentGrid.tsx`, `layout-primitives.test.tsx`, Core dashboard/settings/mail, Calendar dialogs, Forms editor, Datasources, Postbox, Campaign, administration surfaces, and the product-wide primitive check |
| Content sections | Repeated editor/detail section surfaces | Border, surface, compact/default density, stacked flow and block rhythm are centralized without encoding section contents | `ContentSection.tsx`, `layout-primitives.test.tsx`, Datasources, Distribution Lists, Templates, Dataflow, and Workflow |
| Form sections | Reusable heading/description/action/content grouping inside forms | Plain, separated, and panel variants centralize hierarchy and narrow action placement without moving validation, permissions, values, or domain wording into Core | `FormSection.tsx`, `layout-primitives.test.tsx`, Addresses contact editing, and Quick Access preferences |
| Metric groups and drill-downs | Reusable responsive grouping around metric cards with an explicit optional detail affordance | Fixed one-to-five and auto-fit columns, minimum card widths, density, block/inset/zero spacing, and named collapse points replace the product-wide `metric-grid` class and cross-module dashboard overrides; typed link or in-page drill-downs name their destination while summary-only, non-enumerable, derived, or privacy-suppressed values remain inert | `MetricGrid.tsx`, `MetricCard.tsx`, `metric-card.test.tsx`, `layout-primitives.test.tsx`, Core and Dashboard summaries, administration, Campaign, Ops, Files, Search, and dashboard widgets |
| Description lists | Semantic property and fact presentation | Stacked and inline variants, one-to-five list columns, density, term width, wrapping, and responsive collapse replace both `admin-details-grid` and `detail-list`; `DescriptionItem` preserves native `dt`/`dd` anatomy | `DescriptionList.tsx`, `layout-primitives.test.tsx`, Access and Tenancy administration, Audit, Policy, Campaign reports/imports, Docs, Settings, Ops, and Reporting |
| Dialog anatomy | Shared outer dialog plus composable body and footer regions | Size and administration variants, body padding, descriptions, notices, fixed action wrapping, native form flow, and section grouping are centralized; focus trapping and stack lifecycle remain unchanged | `Dialog.tsx`, `DialogAnatomy.tsx`, `dialog-focus.test.tsx`, `layout-primitives.test.tsx`, Addresses, Calendar, Records, Datasources, Distribution Lists, Files, and Templates |
| Definition-editor visuals | Reusable graph palette, canvas chrome, node icon/port geometry, empty overlay and floating activity state | Core owns visual and responsive anatomy while node/edge types, validation, execution, provenance and workflow semantics remain in Dataflow or Workflow | `DefinitionPalette.tsx`, `DefinitionNodeIcon.tsx`, `FloatingStatus.tsx`, shared definition styles, Dataflow and Workflow structure/build checks |
| Shared configuration primitives | Cross-module component contract | Dialog focus, blocker structure, disabled-action focus, route/page/field/action F1 help, unsaved changes, confirmation, loading, alerts, problem lists, and policy provenance are centralized | Core component tests, `CONTEXTUAL_HELP_CONTRACT.md`, and module-permutation build |
## Boundary
Files and Mail are the first two external consumers of the layered
server/credential/policy pattern. Their own repositories retain provider
discovery, transport behavior, authorization, and migration evidence. Remaining
module surfaces are tracked by bounded module-owned issues under GovOPlaN #11;
they are not reasons to add sibling-private behavior to Core.
Raw JSON remains permitted only for diagnostics, expert inspection,
interchange, or conflict evidence. It is not a primary Core configuration
editor.
New headed pages use `PageLayout`; full-canvas modules use `WorkspaceFrame`
and, where applicable, `WorkspaceLayout`, so Core owns the frame and pane
scrolling. Module CSS continues to own unequal domain content layout, never the
shared page, workspace, toolbar, state, list, filter, metric, section, or graph
chrome. Retired copies and module-local component definitions are rejected by
`check-shared-webui-primitives.py`. That check also requires standard dialog
widths to use `Dialog size` and keeps every remaining domain-specific width in
a reviewed, decrease-only exception baseline. The companion layout check now
has zero raw page-frame and zero raw workspace exceptions, discovers semantic
consumers without a hand-maintained route list, requires semantic action bars
on `WorkspaceFrame` routes, and rejects ad-hoc panel-header toolbars.
+97
View File
@@ -0,0 +1,97 @@
# Localization And Contextual Help Quality
## Reference Language
German (`de`) is GovOPlaN's first-class reference target. Every translation key
used by a shipped WebUI must exist in German and English. German completeness is
a release gate; English remains the source-code fallback language so existing
literal labels and external developer APIs do not change semantics.
New installations and tenants default to German. Existing system, tenant, and
user preferences are preserved. The available-language and policy model can
still select another default or disable a package at the relevant scope.
Explicit high-risk help content and browser acceptance are tracked in
[Core #284](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/284).
The platform inventory recognizes both inline locale objects and generated
catalogs declared as `const de` / `const en`. Its strict mode requires both
locales and reports `de` explicitly as the reference locale.
## Help Resolution
Every focusable field and action receives a stable derived F1 identity from the
shared shell, even when the component has no dedicated help text. Resolution
falls back from field/action to dialog or page and then to the module's visible
documentation baseline.
Backend manifests publish explicit topic associations first. Core additionally
associates declared route, navigation, settings, and View surface IDs with the
module's static user or administrator documentation baseline. Feature modules
should still add exact `metadata.help_contexts` entries for consequential,
unfamiliar, policy-controlled, destructive, security-sensitive, or legally
meaningful fields and actions.
The shared retention-policy editor exposes explicit contexts for each stored
data category, audit-detail control, lower-level override switch, target
selector, reload, and save action. The Policy module owns the matching German
administrator guidance. Retention execution surfaces use separate contexts for
dry-run, destructive apply, confirmation, and outcome review so F1 opens the
consequence and recovery guidance closest to the focused control.
Shared controls may set `helpModuleId` when their documentation owner differs
from the containing page; the retention editor uses this to resolve Policy help
from both administration and Campaign surfaces.
The shared reusable-credential manager keeps Access as its documentation owner
and publishes exact contexts for credential kind, secret replacement/removal,
module and server restrictions, lower-scope visibility, activation, save, and
irreversible deletion. This ensures F1 explains secret custody and the effect on
dependent connections from system, tenant, group, user, and personal surfaces.
The source inventory treats literal `helpContextId` and
`data-help-context-id` declarations as authored help associations, including a
native control nested in `FormField`. Dynamic context expressions remain
separate evidence and generic derived fallbacks remain in the richer-help
candidate queue.
The generated `help_review_candidates` list is therefore a content-depth queue,
not a list of controls on which F1 cannot work. It should prioritize:
1. effect, deletion, delivery, retention, disclosure, encryption, and recovery;
2. identity, representation, mandate, institutional context, and purpose;
3. valid-time versus recorded-time selection;
4. provider authority, synchronization, conflict, and outcome unknown;
5. fields whose consequences are not evident from their label.
The shared browser conformance journey mounts the production Help menu and
resolver. It proves that F1 uses the focused control rather than only the page,
maps an exact retention action to Policy-owned administrator documentation,
retains the page context as fallback for derived actions, exposes an accessible
modal at narrow widths, closes with Escape, and restores focus to the triggering
control. Module journeys should add their own exact high-risk mappings; they do
not need to reimplement the keyboard or dialog mechanics.
## Verification
```bash
cd /mnt/DATA/git/govoplan
/mnt/DATA/git/govoplan/.venv/bin/python \
tools/inventory/platform-interface-inventory.py \
--strict --strict-declarations --strict-endpoints
```
The check must report:
- reference locale `de` present and complete;
- no used key missing from `de` or `en`;
- every field has a resolvable F1 context;
- no duplicate stable IDs;
- no undeclared public WebUI surface;
- no stale runtime route or endpoint declaration.
Browser acceptance is part of the focused workspace gate and can be run alone:
```bash
cd /mnt/DATA/git/govoplan-core/webui
npm run test:conformance
```
+401 -10
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/architecture/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:
@@ -130,10 +207,49 @@ Other stable runtime capabilities currently include:
- `identity.directory` and `identity.search`
- `organizations.directory`
- `idm.directory`
- `calendar.outbox` and `calendar.scheduling`
- `idm.directory`, `idm.function_assignments`, `idm.relationships`, and
`idm.assignment_lifecycle`
- `calendar.outbox`, `calendar.scheduling`, `calendar.invitations`, and
`calendar.externalProfiles`
- `poll.scheduling`
- `notifications.dispatch`
- `application_status.projection`
- `payments.requests`
- `workflow.definitionContributions` and `workflow.runtimeWorker`
`calendar.scheduling` keeps workflow modules independent of Calendar-owned
models and transport adapters. Consumers may create tentative events, promote
the selected event in place, and release unused events idempotently. The
provider returns bounded external-delivery and outbox references so consumers
can retain retry state without copying Calendar's synchronization internals.
`application_status.projection` lets a presentation module resolve the tenant
and display or request access to an owner-supplied, deliberately bounded
applicant-status view. The provider retains policy, authorization, token, and
record ownership; consumers must not query provider tables or enlarge the
projection.
`payments.requests` carries replay-safe payment obligations and evidence-bound
manual reconciliation across module boundaries. Procedure modules identify the
source Case or Workflow in the command and retain the returned payment ID;
Payments remains authoritative for amount, currency, state, transaction
reference, and reconciliation evidence. Ledger, invoice, and external payment
providers remain separate follow-on contracts.
The provider-neutral `idm.relationships` contract carries tenant-scoped typed
groups, effective-dated identity relationships, and explicit membership
decisions. It deliberately does not expose IDM persistence models or imply an
Access permission. Consumers can retain source revisions and inclusion or
exclusion provenance while remaining optional-module safe.
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,11 +272,52 @@ 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:
- `addresses.contact_writer`, `addresses.lookup`, `addresses.recipient_source`
- `calendar.outbox`, `calendar.scheduling`
- `addresses.contact_point_resolution`, `addresses.contact_writer`,
`addresses.lookup`, `addresses.recipient_source`
- `calendar.external_profiles`, `calendar.invitations`, `calendar.outbox`,
`calendar.scheduling`
- `campaigns.access`, `campaigns.delivery_tasks`,
`campaigns.mail_policy_context`, `campaigns.policy_context`,
`campaigns.retention`
@@ -169,6 +326,8 @@ contract checks, are:
- `files.access`, `files.campaign_attachments`
- `mail.campaign_delivery`
- `notifications.dispatch`
- `application_status.projection`
- `payments.requests`
- `poll.availability_matrix`, `poll.option_selection`,
`poll.response_collection`, `poll.signed_participation`,
`poll.workflow_context`
@@ -263,6 +422,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 +509,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,
@@ -527,6 +728,46 @@ 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.
For a conclusive external result, modules may commit their local success
projection and the verified terminal checkpoint in one database transaction via
`DurableRecoveryOperation.commit_verified_success`. This does not make the
external provider effect atomic. It prevents a local `succeeded` state from
becoming authoritative when the recovery evidence chain is damaged or the
terminal checkpoint cannot commit.
## Install, Uninstall, And Catalogs
Core owns the install plan, signed catalog validation, license entitlement
@@ -585,11 +826,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",
@@ -604,12 +852,39 @@ 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).
Every public platform interface has a stable declaration identity. Backend
routes, capabilities, interfaces, search providers/sources, permissions,
frontend routes/navigation, and View surfaces derive that identity from typed
`ModuleManifest` values. Typed WebUI capabilities declare IDs for settings,
admin sections, widgets, search contexts, and extension actions. Shared form
and action controls accept `interfaceId` and `helpTopicId`; use module-namespaced
values when another contract, documentation topic, or automated check must
refer to the control across source changes. The static inventory assigns a
line-independent source anchor when an explicit ID is absent and reports that
fact for later review.
Core exposes the sanitized runtime declaration set at
`GET /api/v1/platform/interface-catalog`. The endpoint is read-only, requires
`admin:module:read` or `system:settings:read`, and includes only modules
effective in the caller's active tenant context. It never serializes factories,
credentials, executable callbacks, or mutable module state. Registry validation
rejects conflicting declaration IDs before startup.
WebUI modules receive only the core route context:
- `settings`
- `auth`
A module should call its own API client and module-owned backend routes. Shared API helpers should live in core only when they are truly platform-level concerns.
For ordinary JSON mutations, use Core's `apiPostJson` and `apiPatchJson`
helpers. They preserve the shared authentication, CSRF, error, and request
invalidation behavior while leaving endpoint types and feature semantics in the
owning module.
Modules can also contribute named UI capabilities for explicit extension
points. Capability values must be narrow, typed contracts, not imports from a
@@ -792,6 +1067,16 @@ Decision: templates and reporting are separate modules.
and export targets
- report permissions, report execution history, generated report evidence, and
report-specific retention inputs
Cross-module reports use Core's versioned
`reporting.report_provider.<provider-id>` contract. Source modules own
authorization, parameters, source revisions, effective scope, result schema,
and privacy transforms; Reporting owns discovery, validation, governed
execution, provenance, export history, and the global `/reports` route. The
optional `policy.reporting_governance` capability can only tighten execution,
retention, export, and re-identification-risk handling. Reporting exposes
`reporting.retention` so the Policy-owned retention run can minimize expired
provider results without importing Reporting models.
- downstream export handoff to files, dataflow, connectors, or publication
surfaces
@@ -821,7 +1106,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
@@ -884,7 +1169,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
@@ -897,6 +1182,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`,
@@ -985,6 +1280,61 @@ devserver, development bootstrap, background worker registry, and migration
metadata plan all read the saved desired state from `system_settings` before
building their module registry.
### Tenant entitlement and personal visibility
Deployment activation remains process-wide: one installed and active registry
is shared by every tenant served by that process. Tenant module selection is a
separate entitlement document in `core_scopes.settings.module_entitlements`:
- a system policy marks each installed module `unavailable`, `available`, or
`forced` for one tenant;
- the tenant selection may enable or disable only available modules;
- protected platform modules, forced modules, and transitive dependencies stay
effective;
- malformed explicit entitlement fails closed to protected modules, while an
absent document preserves the pre-entitlement behavior for upgraded tenants;
- an optimistic revision prevents concurrent system and tenant administrators
from silently replacing each other's changes.
The authenticated platform metadata and module route guard intersect global
runtime activation with the active tenant's effective entitlement. Entitlement
does not grant a permission. Access authorization must still allow every API
operation and resource.
The same boundary applies outside authenticated request handling:
- capability factories retain their owning module, and tenant-scoped capability
lookup treats a provider that is unavailable to the tenant as absent;
- workers partition scheduled scans by tenant before claiming rows;
- new work is rejected while a module is unavailable, while already accepted
durable work remains in provider-owned storage and is reported as
`operator_action_required` instead of being dropped or executed;
- Workflow, Dataflow, event consumers, reconciliation jobs, and external-effect
outboxes run inside a tenant execution context, so their optional capability
calls inherit the same provider checks;
- public signed-link modules declare a `public_tenant_resolver`; valid token
context is resolved before the route runs and the module entitlement is then
enforced without requiring an authenticated principal.
Entitlement resolution uses a bounded process-local cache. A local policy
mutation invalidates its tenant entry immediately; changes made by another node
become authoritative after `TENANT_MODULE_ENTITLEMENT_CACHE_TTL_SECONDS`
(five seconds by default). This is a bounded staleness optimization, not an
authorization grant: a cache miss or resolution failure fails closed.
Users and groups do not own another module-runtime state. Every WebUI module
already contributes a root `<module>.module` View surface, so personal and
group module visibility is expressed through Views. View policy controls who
may select, assign, edit, derive, or workflow-activate those projections;
required View assignments can retain required UI. Thus tenant entitlement owns
operational availability, Views own presentation, and Access owns authority.
Capability-style modules such as Encryption must keep activation separate from
domain data state. Making Encryption effective only exposes its capability and
administration surfaces. Encrypting, rekeying, decrypting, or migrating data is
an explicit versioned protection-policy operation owned by Encryption and the
module that owns the data.
Hot enable/disable is a core design principle for every module:
- Core keeps one mutable active `PlatformRegistry` object and swaps its manifest
@@ -1050,8 +1400,10 @@ The package install-plan API records operator intent only:
- `GET /api/v1/admin/system/modules/package-catalog` reads approved package
references from `GOVOPLAN_MODULE_PACKAGE_CATALOG` so operators can add known
module refs to the install plan without typing them manually. The endpoint
also reports catalog validity, channel, signature, trust state, and the
configured path.
also reports catalog validity, channel, signature, trust state, source and
artifact provenance, release availability, configuration requirements, and
per-entry compatibility/blocker state. Withdrawn entries are visible for
diagnosis but cannot be planned.
- `POST /api/v1/admin/system/modules/install-plan/catalog/{module_id}` saves
a planned install or update row from a validated catalog entry. Installed
modules are planned as updates. Catalog signature and approved-channel policy
@@ -1092,6 +1444,11 @@ The package install-plan API records operator intent only:
default; successful uninstalls are removed from saved startup state by default.
Use `--no-activate-installed-modules` or
`--keep-uninstalled-modules-in-desired` only for staged rollout workflows.
- Every non-dry installer and live active-graph mutation acquires the
deployment-wide `core:module-lifecycle:deployment` lease and records a Core
recovery operation. Unresolved effects block later lifecycle changes. The
operation modes and operator reconciliation contract are defined in
`MODULE_LIFECYCLE_RECOVERY.md`.
- `govoplan-module-installer --supervise --migrate --health-url http://127.0.0.1:8000/health --restart-command '<restart govoplan server>'`
is the preferred disruptive-change path. It applies the plan, optionally runs
migrations in a fresh Python process after a fresh-process manifest
@@ -1141,6 +1498,13 @@ 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;
- official runtime images carry the full verified package profile, while the
desired module graph controls activation and tenant/View/Policy contracts
control availability and presentation; package lifecycle must not be reused
as a tenant or user visibility switch;
- installed module manifests must be compatible with the supported manifest
contract and current core version;
- uninstalling `tenancy`, `access`, or `admin` is blocked;
@@ -1241,6 +1605,33 @@ The first implementation is a platform access gate. It does not replace
database backups, process supervision, migration checks, or external load
balancer maintenance pages.
## Connector Runtime Contract
Core defines provider-neutral connector preview and diagnostic primitives in
`govoplan_core.core.connector_runtime`. The contract keeps optional modules
decoupled: Connectors owns transport, endpoint discovery, retries, and protocol
health; the consuming domain module owns mappings, validation, reconciliation,
and mutations of its records.
Every dry run is bounded and identifies the source revision, source fingerprint,
immutable input hash, effects, and redacted diagnostics. Its summary must match
the returned effect list exactly. An apply token is usable only when the preview
is complete, current, conflict-free, and contains no error diagnostic. Endpoint
URLs never contain credentials; only credential-envelope references cross the
contract. Provider-specific details belong in sanitized provenance rather than
in a shared domain schema.
## Semantic Documentation Subject Contract
Optional modules expose configured artifacts that can be documented through
the module-scoped `documentation.semantic_subjects.<module_id>` capability.
Core supplies stable tenant-scoped references, typed nested anchors, safe
localized descriptors, revision/fingerprint review signals, and explicit
availability states. Providers remain responsible for authorization and do not
expose configuration payloads or credentials. Docs discovers the capability
and owns authored content; it does not import feature internals. See
`SEMANTIC_DOCUMENTATION_SUBJECTS.md` for the contract and adoption rules.
## Build And Verification
Backend verification from core:
+66
View File
@@ -0,0 +1,66 @@
# Module Lifecycle Recovery
## Migration Revision Namespace
All enabled module migration directories are assembled into one Alembic graph. Revision IDs are therefore global across Core and every module even though each module owns a separate `migrations/versions` directory. Core validates literal revision declarations before constructing the graph and rejects duplicates with both file paths. A module must assign a new globally unique revision ID; reusing another module's ID can otherwise make Alembic treat an unrelated schema change as already applied or report an ancestor/head overlap.
When correcting a collision that has already reached a database, first verify the schema objects that identify which migration actually ran. Rename the unapplied migration, or transactionally translate the corresponding `alembic_version` row when the applied owner is unambiguous. Never add both colliding IDs as heads or blindly stamp the database.
Package changes and live module-graph changes use Core's durable recovery
ledger. The local `install.lock` still prevents duplicate work in one runtime
directory; the database lease `core:module-lifecycle:deployment` is the
deployment-wide authority across API, installer, worker, and scheduler nodes.
## Declared Boundaries
| Operation | Recovery mode | Completion condition |
| --- | --- | --- |
| `module-lifecycle.pre-migration` | compensation | package, WebUI, manifest, and desired-graph evidence match |
| `module-lifecycle.post-migration` | forward recovery | migration tasks, manifests, desired graph, restart, and health are verified |
| `module-retirement.destroy-data` | snapshot restore | a hashed, restore-checked backup exists and retirement state is verified |
| `module-runtime.apply-graph` | compensation | hooks, capability contexts, active graph, and workflow contributions match |
The installer prepares the recovery operation before it captures the database
snapshot. A full database restore therefore retains the prepared operation and
its fence instead of erasing the fact that a mutation was attempted. Backup
artifacts are hashed and sized before any package, migration, or retirement
effect starts.
Every command boundary records the command source and canonical hashes of the
redacted command/result records. Credentials, database URLs, command output,
and package-registry secrets are never copied into recovery evidence.
## Failure And Retry Rules
- A conclusive failure before effects is terminal `failed`.
- A command or compensatable effect that started but did not complete is
`recovery_required`.
- A lost or unexpected outcome after a migration/external boundary is
`outcome_unknown`.
- A verified package/database rollback becomes `recovered`.
- A supervised install becomes `succeeded` only after restart and all configured
health probes succeed.
An unresolved lifecycle operation blocks every later lifecycle mutation on the
same deployment fence, even after its execution lease is released. Operators
must inspect the checkpoint chain and run record, restore or complete the
declared recovery path, and explicitly reconcile the operation. A new install
must not be used as an implicit retry.
Live graph changes use the same fence. A non-migrating hook or registry failure
restores the prior in-process graph and records verified compensation. A failure
after migrations begin remains unresolved because restoring the process-local
registry does not reverse database schema effects.
## Operator Evidence
The installer run record contains the recovery operation id, mode, plan hash,
and current lifecycle status. The Ops recovery view is authoritative for the
durable state and evidence-chain result. Keep both the run directory and the
state-service backup evidence until the operation is terminal and the normal
retention policy permits removal.
Run the module installer rollback drill and recovery-runtime test matrix before
enabling lifecycle mutation in a new deployment. Shared-state deployments must
still use immutable release images; the ledger does not make in-place package
mutation across replicas safe.
+158
View File
@@ -0,0 +1,158 @@
# Page Layout and Action Guidelines
This document defines the binding composition grammar for headed GovOPlaN
pages. Core owns the reusable anatomy; each module owns its domain actions,
wording, authorization, consequences, and data state.
## Required Page Frame
- Use `PageLayout` for every headed standalone, workspace, or embedded page.
- Declare exactly one semantic `archetype`; do not infer page intent from the
`mode`, which controls geometry and scroll ownership only.
- Use `WorkspaceFrame` for a full-height module surface and
`WorkspaceLayout` only where navigation/content or list/detail panes are
genuinely part of the interaction.
- Put page-wide feedback in `PageLayout` notices. Use `DismissibleAlert` for a
recoverable warning or failure and `StatePanel` when the entire surface is
loading, empty, unavailable, or blocked.
- Do not reproduce shared page padding, heading, toolbar, form-grid, section,
table, dialog, or breakpoint CSS in a module.
## Product Side Rail
Module manifests contribute stable navigation surface identifiers, labels,
paths, icons, and default order. Core owns the side-rail composition and the
shared `NavigationPreferenceEditor`; modules must not fork this editor or
persist their own rail ordering.
Navigation preferences are layered in this order: module defaults, system,
tenant, then user. Each higher layer may reorder or change visibility. System
and tenant administrators may lock an entry visible; a lower layer can still
move that entry, but cannot hide it. Personal preferences cannot create locks.
An unset preference inherits the complete lower layer, while “Use inherited
order” removes the current layer rather than copying its values. Unknown item
identifiers remain harmless so uninstalling, disabling, or later reinstalling
a module does not corrupt the rail.
The platform module response projects module, system, and tenant layer states
alongside the effective user state. Editors must initialize from the layer
immediately below the scope they edit, so a system or tenant administrator's
personal preference is never promoted accidentally. Preference saves refresh
the platform module projection. View policy, permissions, and tenant module
entitlements remain independent final visibility gates; changing rail
preferences never grants access.
## Semantic Page Archetypes
| Archetype | Use when |
| --- | --- |
| `overview` | The page summarizes health, metrics, or several peer areas without owning one primary collection or draft. |
| `collection` | The primary object is a searchable/listable collection and Create, when available, applies to that collection. |
| `detail` | The page primarily presents one record, report, or immutable projection. |
| `editor` | The page owns one explicit draft with Save and Discard behavior. |
| `workspace` | The page coordinates several panes, stages, or task-local operations that cannot honestly be reduced to one record or draft. |
The archetype remains stable for the current interaction. A page may switch
from `overview` to `editor` when the user explicitly enters configuration
mode. It must not call a page an editor merely because a dialog or an inline
filter is editable.
## Page Action Rules
Pass one `PageActionBar` to the `PageLayout` `actions` slot. Full-canvas
workspaces use the same contract through `WorkspaceActionBar`, with an explicit
`workspace`, `collection-pane`, `detail-pane`, or `editor-pane` scope. The
variant makes the surface's intent inspectable and preserves the same keyboard
and visual order across modules. `ActionToolbar` remains the lower-level
component for section-local controls; it is not a substitute for a semantic
page or pane action bar.
| Page kind | Leading group | Trailing group |
| --- | --- | --- |
| Overview | Reload when refreshable, then context | Help, then ordinary primary actions |
| Collection | Reload when refreshable, then collection context such as export | Help, then Create at the far right |
| Detail | Reload when refreshable, then object context | Help, ordinary primary actions, then a separated destructive group |
| Editor | Reload only when refresh is a distinct safe operation, then context | Dirty state, Help, ordinary primary actions, separated destructive actions, Discard, then Save at the far right |
| Workspace | Reload when the coordinated projection can become stale, then task context | Help, ordinary primary actions, then a separated destructive group |
Reload means re-fetch or re-evaluate the current surface. A page declaring
`refreshable` must provide it, and a non-refreshable page must not use Reload as
a synonym for Cancel, Reset, or Discard. Reload never silently destroys a dirty
draft. Create is a collection-wide action and is not duplicated in a
persistent side panel. Save is present only where the page owns an editable
draft; a read-only detail page must not display a disabled or inert Save merely
to fill the slot.
Editor bars always keep Discard and Save visible. Their required `state`
projection is one of `clean`, `dirty`, `invalid`, `saving`, `save-failed`, or
`conflict`, and the central component announces it through a live status label.
Clean and saving states disable both persistence actions; invalid disables Save
while retaining Discard. Failed saves and conflicts keep the draft recoverable
and allow an authorized retry after the module has shown the owning error or
conflict evidence. A module may add a more specific validation, policy, or
permission blocker. The editor must register its draft with
`useUnsavedDraftGuard` (or a shared hook that uses the same registration
contract), so browser unload, route navigation, section changes, Reload, and
the explicit Discard path cannot silently lose work.
Reload is rendered by Core from a descriptor rather than passed as arbitrary
button markup. It can project `current`, `stale`, `reloading`, or
`reload-failed`; `loading` is the shorthand for `reloading`. A failed refresh
must preserve usable loaded data, expose its stale/failure state, and leave
Reload available for recovery. Reload goes through the same unsaved-navigation
guard as route changes.
Destructive page actions use `destructiveActions`; never put a danger action in
`contextActions` or the ordinary primary group. Core renders a persistent
visual and semantic boundary before this group. In an editor it precedes the
Discard/Save pair, keeping Save in the final keyboard and visual position.
`PageActionBar` controls non-editor placement and owns the standard editor
persistence buttons. Other actions continue to use central
`Button`, `IconButton`, or `TableActionGroup` components. When an action is
visible but unavailable because of permission, target, policy, state, or
validation, keep it in its stable slot and supply `disabledReason`. Do not
silently hide a normally applicable action.
## Forms and Dialogs
- Compose forms from `FormLayout`/`FormGrid`, `FormSection`, and `FormField`.
- Use `FieldLabel` through `FormField` for every field that is not genuinely
self-explanatory; record justified omissions in the owning UI ledger.
- Use `Dialog`, `DialogForm`, `DialogSection`, and `DialogActions` for modal
work. A dialog can be domain-specific while its anatomy remains central.
- Use `useUnsavedDraftGuard` for explicit Discard and guarded navigation on an
editable page or dialog.
- Explain irreversible or operationally consequential actions before the
commit button, including reversibility and durable evidence.
## Collections and Details
- Use `FilterBar` for collection query controls and `DataGrid` for tabular
collections. Keep a single ordered `TableActionGroup` action set per table.
- Use `MetricGrid`/`MetricCard` for summary measures, `Card` or
`ContentSection` for logical sections, and `DescriptionList` for labelled
facts.
- Add a `MetricCard.drilldown` only when the displayed measure has a useful,
authorized underlying collection or detail. Name the destination explicitly
(for example, “Review failed deliveries”) and preserve the current scope and
filters in its `href` or action. The card itself remains non-interactive so
the action is visible and keyboard-predictable. Derived, privacy-suppressed,
non-enumerable, or purely informational aggregates remain plain metrics;
when an ordinarily available drill-down is temporarily blocked, keep its
action and provide `disabledReason`.
- Preserve loaded data after a refresh failure and mark it stale; offer Reload
as the recovery action. Distinguish initial loading, empty, unavailable,
permission-blocked, conflict, success, and retry states.
## Review Evidence
Every new or changed page or workspace pane must have structural evidence for
its frame, semantic archetype/scope and slot order, refresh declaration, shared
component usage, stable disabled actions, dirty guard, destructive boundary,
and module-owned help identity. Type checks enforce conditional Reload and
editor persistence props. The product check discovers all consumers, rejects
undeclared archetypes and `ActionToolbar` panel-header copies, and requires
semantic actions for every `WorkspaceFrame` route. Browser conformance confirms
keyboard order, lifecycle changes, accessibility, destructive separation,
narrow wrapping, and screenshot geometry.
+56
View File
@@ -13,6 +13,8 @@ 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. |
| Function assignment governance | `govoplan-policy` | capability `policy.functionAssignmentGovernance` | Returns current review steps, delegation depth/validity ceilings, and explicit timed-escalation targets consumed by IDM. |
## Policy Decision
@@ -111,6 +113,57 @@ 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.
## Function Assignment Delegation And Escalation
`FunctionAssignmentGovernanceDecision` is the versioned cross-module contract
for request/grant review. In addition to the required holder, authority, and
recipient steps, it returns `delegation_allowed`,
`maximum_delegation_depth`, `maximum_delegated_validity_days`, and typed
`FunctionAssignmentEscalationRule` entries. Each escalation entry binds one
review step to an exact target function and timeout.
The decision is a current ceiling, not durable authorization. IDM must recheck
the complete assignment-source chain and all recorded decisions before final
application. An elapsed timeout creates explicit state and evidence; it must
never be interpreted as approval or as permission to silently substitute an
approver. Missing providers, malformed rules, invalid chains, or tightened
limits fail closed with an explainable reason.
## Bounded Impact-Subject Providers
Policy impact previews discover optional subject providers through capability
names beginning with `policy.impactSubjects.`. The suffix is the stable
provider ID; for example, Views contributes `policy.impactSubjects.views`.
Providers implement `PolicyImpactSubjectProvider` and receive a
`PolicyImpactPopulationRequest` containing the active tenant, policy family,
an explicit selector, actor scopes, detail-disclosure decision, and a limit of
at most 500. They return `PolicyImpactSubjectBatch` with unique opaque subject
references and an explicit `complete`, `sampled`, `truncated`, or `unavailable`
state. An unavailable batch must explain the gap, and a total may never be
smaller than the returned subject count.
Core does not scan module data or evaluate domain policy. The owning module
selects and permission-filters its candidates; Policy compares the current and
proposed decisions and controls response disclosure. A caller must select one
or more provider populations explicitly. This preserves optional-module
boundaries and prevents a seemingly harmless preview from becoming an
unbounded platform query. Providers must not include credentials, secrets, or
unfiltered cross-tenant labels in subject attributes.
## Frontend Contract
Policy UIs must:
@@ -123,6 +176,9 @@ Policy UIs must:
lower-level limit to `false`
- avoid sending locked fields or re-enable attempts in save payloads
- show inherited values separately from local overrides
- require a current impact preview before enabling a governed high-impact save,
preserve its proposal hash on commit, and explain incomplete population
coverage rather than presenting unavailable providers as zero impact
The core WebUI helper `privacyRetentionParentAllowsField()` centralizes the
field-lock decision used by the retention editor and its lightweight module
+55 -4
View File
@@ -1,9 +1,12 @@
# Postbox End-To-End Encryption Architecture
This document records the strategic encryption target for GovOPlaN postboxes.
It does not require the first postbox implementation to ship full E2EE, but it
defines the architecture so early data models and APIs do not make the stronger
model impossible.
This document records the encryption boundary for GovOPlaN postboxes. Postbox
now implements the server-side contracts for three selectable profiles:
unencrypted content, institution-managed server envelopes, and externally
produced E2EE envelopes. The E2EE contract is operational—the server rejects
plaintext and retains ciphertext, signed manifests, wrapped keys, and digest
evidence—but a reviewed browser/device client and private-key custody provider
remain separately deployed responsibilities.
The core principle is that a postbox can become a trusted administrative
communication channel without requiring the server to see plaintext content.
@@ -35,6 +38,54 @@ Algorithm choices should remain replaceable behind a crypto profile. The first
profile should prefer standard, reviewed primitives such as HPKE for key
wrapping and AEAD encryption for content.
## Product Profiles And Default
The content-protection policy is configurable per exact Postbox or immutable
template revision:
- `server_envelope_v1` is the recommended default. An institution-selected
Encryption vault controls server-readable envelopes and their migration
evidence. It is not end-to-end encryption.
- `external_e2ee_v1` is server-blind. An approved client or producer supplies
the ciphertext reference, signed manifest, wrapped recipient keys, key epoch,
and SHA-256 plaintext digest. GovOPlaN has no private key that can decrypt it.
- `plaintext_v1` stores clear content for institutions that explicitly choose
that boundary.
Operational metadata—including subject, routing, participants,
classifications, timestamps, attachment references, receipts, and retention
state—remains visible under every profile. Administrators therefore choose a
content-protection boundary, not a metadata-anonymity profile.
The standard policy grants new incumbents history since assignment, uses key
rewrapping for ordinary rotation and content re-encryption after compromise,
requires two-person institutional recovery and dual-control hand-over,
emergency, export, and destruction, requires strong external identity, and
limits vacancy escalation to metadata. Deployments may select other policy
values rather than inheriting a decision from GovOPlaN.
## Governed Profile Changes
A profile transition applies to new messages immediately and increments the
Postbox key epoch. Retained history can remain under the previous profile or be
migrated. The transition ledger records source and target profiles/vaults,
authority route, consent and key-holder evidence, quorum, reason, immutable
configuration snapshot, per-message source and target digest, and outcome.
Plaintext and managed-envelope migrations can use the server-side Encryption
capability. Managed decrypt, export, and re-encryption operations also create
Encryption migration records so old envelopes are disposed of through the
governed provider contract. Any transition to or from E2EE pauses each retained
message for an approved client transform. The client must return plaintext or
ciphertext as appropriate, plus evidence and the original content digest;
Postbox verifies digest continuity before changing the stored representation.
Leaving E2EE requires user-consent evidence, while changing managed history
requires institutional key-holder evidence. Dual control can require both.
This transition mechanism cannot revoke plaintext already decrypted, copied,
printed, or exported. Administrators must explicitly acknowledge that residual
disclosure before a transition is accepted.
## Identity And Device Keys
The platform should distinguish:
+74 -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:
@@ -138,6 +141,73 @@ connector or module issue.
- Owner/priority: `govoplan-mail`, `govoplan-calendar`,
`govoplan-connectors`, Wave 1/2.
#### Collaboration-suite boundary and hand-offs
Collaboration remains connector-first. The product named below never changes
which GovOPlaN module owns the administrative meaning of the work:
| External family | Initial posture | GovOPlaN semantic owner | Connector-owned boundary |
| --- | --- | --- | --- |
| Collabora Online, OnlyOffice, Nextcloud Office | Link an externally edited document and its editing session; import a governed rendition only when required | DMS owns document/version, lock, review, approval, retention, and collaboration-session evidence; Files owns stored bytes | Discovery, endpoint health, WOPI/vendor session exchange, callbacks, and provider object references |
| Matrix, Mattermost, Rocket.Chat, Nextcloud Talk | Create or link a room/thread for a governed work context; do not mirror all conversation history by default | The initiating Case, Workflow, or Task owns the work-context link and disposition; DMS/Records own retained evidence deliberately captured from it | Room/thread creation, membership synchronization, webhook/event normalization, and stable external links |
| Jitsi and BigBlueButton | Provision or link a conference for an existing appointment/event | Appointments owns booking intent; Calendar owns event, attendee, invitation, and time semantics | Conference provisioning, join/moderator references, provider lifecycle, and bounded attendance/result callbacks |
| OpenProject and comparable project suites | Link first, then publish or synchronize selected work packages | Tasks owns GovOPlaN task state; Workflow owns orchestration; Cases own case state and evidence references | Project/work-package lookup, publish/synchronize transport, webhooks, version tokens, and external URLs |
| Cross-suite activity streams | Consume normalized, bounded events only for an authorized work context | The receiving module decides whether an event changes state or becomes evidence; Audit records the GovOPlaN operation | Provider subscriptions, cursor/checkpoint handling, signature validation, event normalization, and replay protection |
Native collaboration behavior is justified only when GovOPlaN must own the
semantic state, authorization decision, audit evidence, retention/legal-hold
rule, or configuration-package fragment. Endpoint profiles, tokens, health,
protocol clients, provider IDs, retries, and webhook transport remain in
Connectors (or the owning protocol connector). A feature module consumes a
Core capability/DTO and must still start and fail explicitly when that optional
connector is absent; it never imports a provider client.
The minimum hand-off sequences are:
1. **Appointment to conference:** Appointments confirms the booking intent;
Calendar creates or updates the event and invitations; an optional
conference connector provisions the room idempotently and returns an
opaque join reference. Calendar stores that reference with the event, not
the provider credential.
2. **Case or Workflow to collaborative document:** the initiating module asks
DMS for a governed document/session; DMS requests an optional office-suite
connector session and retains version, lock, approval, and callback
evidence. The Case/Workflow keeps only the DMS reference.
3. **Case, Workflow, or Task to chat:** the semantic owner requests a room or
thread with an idempotency key and bounded membership intent. The connector
returns an external reference; capturing messages as evidence requires an
explicit DMS/Records action and policy decision.
4. **Task or Workflow to project suite:** Tasks supplies the task payload and
Workflow supplies correlation; the OpenProject connector publishes or
reconciles the work package and returns versioned external-reference and
retry/conflict evidence. Neither consumer writes connector tables.
Every executable collaboration connector must pass the common connector
contract checks plus a provider-focused minimum proof:
- optional-module startup and partial compositions work without the provider;
- profile health uses secret references and redacts credentials and remote
response bodies;
- tenant/resource authorization is checked before discovery, provisioning,
lookup, synchronization, or evidence capture;
- dry-run/simulation performs no remote mutation and explains unsupported
operations;
- create/publish calls are idempotent, retries preserve the same external
reference, and outcome-unknown or version conflicts remain reconcilable;
- callbacks/webhooks verify authenticity, tenant/profile binding, replay
protection, and bounded payloads;
- disable/retire behavior revokes new use while preserving non-secret audit and
external-reference evidence;
- Collabora/OnlyOffice prove discovery plus one non-production editing-session
round trip; Matrix/Mattermost/Rocket.Chat prove room lookup/create plus one
authenticated bounded event; Jitsi/BigBlueButton prove conference
provision/cancel; OpenProject proves project/work-package lookup, idempotent
publish, and conflict handling.
These are connector acceptance tests, not a claim that those connectors are
already implemented. Their implementation state remains in the owning
connector issues and catalogue.
### Payment And Public Cashier Systems
- Strategy: integrate/export/import; keep the payment provider or cashier as
@@ -172,8 +242,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
+94
View File
@@ -0,0 +1,94 @@
# Records Filing Contract
Core exposes a small provider-neutral contract for filing exact source
revisions into an institutional record. Core does not own records semantics,
source-object authorization, or source bytes. `govoplan-records` owns filing
orchestration and chronology; each source module owns resolution of its exact
revision.
## Capability Names
- `records.filing` is supplied by the enabled Records module.
- `records.source.<module>` is supplied by an enabled source module, for
example `records.source.files` or `records.source.cases`.
- `records.archive.<provider>` is supplied by an enabled archive-transfer
adapter. Discovery does not imply conformance or current health.
Callers discover capabilities through the module registry. They must not
import optional source-module internals.
## Exact Source Identity
`RecordSourceLocator` identifies one tenant, source module, resource type,
resource ID, and immutable source revision. A source provider must:
1. reject cross-tenant resolution;
2. require a non-empty purpose;
3. re-evaluate the caller's current module and object authorization;
4. resolve exactly the requested revision, never a mutable "current" alias;
5. return safe display/provenance metadata and a SHA-256 digest when the source
has stable bytes or a canonical snapshot;
6. fail closed when the revision is missing, quarantined, corrupt, or no longer
authorized.
Historical Records browsing never revives historical access rights. The
source's current authorization decision remains authoritative when filing.
## Filing Semantics
`RecordFilingRequest` binds the exact source to a record, purpose, filing
reason, relationship, institutional context, and idempotency key. Records must
persist source identity and resolution evidence together with the filing actor,
represented capacity, valid time, recorded time, and immutable chronology.
An idempotency key may replay only an identical request. A conflicting reuse
must fail. Filing does not transfer ownership of source content and must not
silently copy mutable source state.
## Versioning
The Python DTOs and protocols live in `govoplan_core.core.records`. The
manifest interface `records.filing` starts at `1.0.0`. Incompatible DTO or
behavior changes require a new interface version and release impact analysis;
additional optional metadata remains backward compatible.
## Initial Providers
- Files resolves an exact managed `FileVersion`, verifies current Files access
and blob integrity, and returns its stored content digest.
- Cases resolves an exact immutable case revision after current case access and
returns a digest of the canonical revision snapshot.
Provider-specific selection UI belongs to the source module. The generic
Records dialog remains a diagnostic/manual fallback for exact identifiers.
## Archive Transfer Boundary
`RecordTransferPackage` binds a stable package ID, record revision, provider
profile, canonical manifest, and manifest SHA-256. An archive provider exposes
`RecordArchiveProviderState` before dispatch and accepts only a
`RecordArchiveTransferRequest` for a declared healthy profile. Its receipt must
identify the same package and provider and return one bounded outcome:
`accepted`, `rejected`, or `outcome_unknown`.
An unknown outcome is never retry-safe. Callers must retain the intent and
reconcile it against the provider before another effect. Provider state also
declares authority mode, freshness, limitations, and whether the provider is a
simulation. Credentials, transport configuration, archive-specific package
schemas, and custody semantics remain provider-owned.
Records includes `records.archive.simulation` to prove package and receipt
handling. The simulation is explicitly non-conformant, transfers no custody,
and cannot be used as evidence of an archive handoff. A real provider requires
a selected target/profile, provider-specific recovery declaration, and target
test evidence.
## Form Evidence Boundary
Form attachments use the separate provider-neutral contract in
`govoplan_core.core.form_evidence`. Forms Runtime requests short-lived,
purpose-bound upload grants and re-inspects the exact provider-owned evidence
before final submission. The provider keeps byte storage, quarantine,
classification, and retention ownership; Forms Runtime stores only immutable
evidence references and bounded verification results. This contract is not an
alternative path for Records filing or archive custody.
+73 -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`
@@ -196,6 +197,13 @@ If both file and URL are set, the URL wins. The cache is used when a remote
fetch fails, so an operator can still inspect the last known catalog. A cached
catalog must still pass signature, freshness, channel, and replay validation.
If neither source is configured, the Admin package directory discovers the
official public stable catalog at
`https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json`. Core verifies
that fallback against the public key pinned in the installed Core package. An
explicit deployment catalog always takes precedence; a configured source that
is unavailable or invalid fails closed instead of silently falling back.
An official catalog is a JSON object with:
- `catalog_version`
@@ -211,6 +219,14 @@ Each module entry can declare:
- backend package name and pinned install reference
- WebUI package name and pinned install reference
- `artifact_integrity` for each package, including the HTTPS registry URL,
filename, byte size, SHA-256, package identity, source tag, and source commit
- `source`, binding the repository and immutable tag/commit identity, with
optional HTTPS repository and revision links
- `availability`, either `available` or `withdrawn`; a withdrawn entry must
carry an operator-readable `availability_reason` and cannot be planned
- `configuration_requirements` and an optional HTTPS `release_notes_url` for
prerequisites and release-specific operator guidance
- display metadata and tags
- `license_features`, the feature entitlements required to plan that install
- `dependencies` and `optional_dependencies`, the module ids expected in the
@@ -238,6 +254,12 @@ Each module entry can declare:
- `requires_interfaces`, named interface contracts and version ranges required
by this module
Core validates these fields before exposing the directory. Admin derives a
read-only catalog state from the installed package set, catalog dependency
closure, named-interface providers, current-version window, availability, and
generic license policy. This is an early operator diagnostic; trusted installer
preflight remains the authoritative mutation gate.
The signature is Ed25519 over canonical JSON with both `signature` and
`signatures` removed. Core accepts the legacy single `signature` field and the
new `signatures` array.
@@ -301,6 +323,12 @@ Catalog provenance changes preflight severity:
plans, so operators can still use offline or emergency package refs
- valid-catalog warnings, such as intentionally unsigned local catalogs when
signature enforcement is disabled, remain warnings
- a saved catalog plan must match the currently validated entry exactly;
altered package refs, artifact identities, channel, sequence, trust state, or
signing-key identity block the run and require replanning
- a trusted remote artifact is downloaded before mutation into a private
SHA-256-addressed installer cache, checked for exact size and digest, and
passed to `pip` or npm only as that verified local file
- selected catalog entries with unsatisfied non-optional named interface ranges
block activation before the installer runs
- selected catalog entries whose target dependencies are neither installed nor
@@ -518,6 +546,11 @@ Catalog entries can require license features:
Core checks those requirements against an offline license file before allowing
the entry into the install plan.
Official open-source GovOPlaN entries do not declare license features. The
license contract remains generic for external catalogs, deployment presets,
configuration/package directories, and support offerings; it gates only an
entry that explicitly asks for a feature.
```bash
GOVOPLAN_LICENSE_FILE=/srv/govoplan/license.json
GOVOPLAN_LICENSE_ENFORCEMENT=true
@@ -788,10 +821,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 +931,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
+26
View File
@@ -0,0 +1,26 @@
# Search event indexing contract
Core defines, but does not implement, the optional Search indexing boundary.
Feature modules register `SearchSourceProvider` implementations for bounded
backfills and live authorization checks. A provider may additionally implement
`SearchEventSourceProvider` to translate a committed `PlatformEvent` into one
or more authoritative `SearchIndexChange` values.
When the Search index-writer capability is active, the platform event worker
uses the durable consumer identity `search.indexing.v1`. It accepts only public
and internal events, passes the outbox delivery key to each event-capable
source, and then advances a bounded batch of queued index changes in the same
worker transaction. Stable change IDs make delivery replay idempotent.
The boundary has three non-negotiable rules:
- a source may emit changes only for its registered module, provider, resource
type, and event tenant;
- Search validates every upsert document before queueing it and rejects secret
metadata keys;
- an index ACL is only a candidate filter. Resources marked for authorization
recheck are returned only after the owning source explicitly allows the
current principal at query time.
Search and its worker remain optional. Core-only startup and feature-module
operation do not require the Search package.
+1 -1
View File
@@ -12,4 +12,4 @@ tools/checks/security-audit/run.sh --mode full --scope govoplan
Canonical documentation:
- `/mnt/DATA/git/govoplan/docs/SECURITY_AUDIT.md`
- `/mnt/DATA/git/govoplan/docs/operations/SECURITY_AUDIT.md`
+118
View File
@@ -0,0 +1,118 @@
# Semantic Documentation Subjects
## Purpose And Ownership
The semantic-documentation subject contract lets an optional module expose the
configured artifacts that administrators may document: for example a form, a
form field, a workflow, or a workflow state. It is a discovery and resolution
contract, not a second configuration API.
The module that owns an artifact also owns its subject provider, authorization,
identity, revision, route, and lifecycle semantics. Docs may discover those
providers through Core and attach authored documentation to their stable
references. Docs must not import the feature module, read its tables, or copy
configuration content into a generic index.
This contract is additive to manifest `DocumentationTopic` contributions and
configured-state `documentation_providers`. Every providing module must retain
static user and administrator documentation baselines. The baselines explain
the feature even when the provider is disabled, unavailable, or has no
configured subjects.
## Identity And Versioning
`SemanticDocumentationSubjectReference` identifies a subject with:
- owning module and tenant;
- a module-defined subject kind and stable identifier;
- an optional typed nested anchor, such as `field/registration-number`;
- the revision and canonical fingerprint observed when documentation was
authored or reviewed.
The `stable_key` derives only from identity. A rename or configuration revision
therefore does not detach existing documentation. A nested anchor has its own
identity so a field can be documented independently from its form.
Providers must resolve an old reference as one of:
- `available`: the observed revision/fingerprint is still current;
- `changed`: the same stable subject has changed and may need review;
- `superseded`: another stable reference replaced it;
- `missing`: the subject was removed or is no longer resolvable;
- `temporarily_unavailable`: the provider cannot currently determine state.
Absence is not authorization. A provider returns `None` when the principal may
not learn whether a subject exists. Core also rejects cross-tenant list and
resolution requests before calling a provider.
## Safe Projection
Descriptors contain only bounded, explicit presentation fields: localized
labels and descriptions, breadcrumbs, a local route, audience,
classification, and required scopes. They must not contain credentials,
personal data, arbitrary provider metadata, configuration payloads, or the
authored documentation itself. Routes are application-local and are still
subject to normal route authorization.
The fingerprint is a review signal, not a concurrency token or a content hash
that callers may use to reconstruct configuration. Providers should calculate
it from the smallest canonical JSON projection whose semantic changes require
documentation review. Volatile timestamps and secrets must be excluded.
## Provider Registration
A provider is registered under its exact module-scoped capability name:
```python
from govoplan_core.core.modules import CapabilityDocumentation
from govoplan_core.core.semantic_documentation import (
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
semantic_documentation_subject_capability,
)
capability = semantic_documentation_subject_capability("forms")
manifest = ModuleManifest(
id="forms",
# ...
capability_factories={capability: build_semantic_subject_provider},
capability_documentation={
capability: CapabilityDocumentation(
label="Form semantic subjects",
summary="Lists authorized configured forms and fields for Docs.",
contract_version=SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
documentation_types=("admin", "user"),
)
},
documentation=(admin_baseline, user_baseline),
)
```
The capability is `documentation.semantic_subjects.<module_id>`. Registry
validation rejects a mismatched owner, missing capability documentation, a
wrong contract version, or missing static baselines.
`list_semantic_documentation_subjects` performs authorized, paginated discovery
across installed providers. `resolve_semantic_documentation_subject` targets
one owner without loading another feature module. Providers must apply the
current tenant and principal on every call and must not infer visibility from a
previous list result.
## Lifecycle And Integration Rules
- Keep subject and anchor identifiers stable across display-name and route
changes.
- Return `superseded` only with the replacement reference; do not silently
rewrite stored references.
- Return a reason code for missing or temporarily unavailable subjects without
exposing sensitive detail.
- Reauthorize both discovery and resolution. Stored documentation references
confer no access to a live artifact.
- Treat a changed fingerprint as a request for editorial review. It does not
automatically invalidate or publish authored documentation.
- Removing a feature module leaves references resolvable as provider
unavailable. Docs can preserve history without importing the module.
Forms, Workflow, and later modules should implement their subject providers in
their own repositories. Docs owns the authored semantic-documentation records,
review workflow, and projection UI.
+162
View File
@@ -0,0 +1,162 @@
# 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.
`stat` and `list_objects` return object size plus a UTC `modified_at` value when
the backend can prove it. Reconciliation and retention code may use that value
for conservative grace periods, but must treat a missing timestamp as
ineligible for automatic deletion rather than guessing an age.
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.
Worker metadata also records the orchestrator pool and declared concurrency.
Every Celery prefork child disposes the SQLAlchemy pool inherited from its
parent and creates a process-local pool before handling work. Deployment
rendering must therefore budget one database pool for the worker parent and
each child. Ops compares active queue ownership, software versions, and the
order-independent module-composition hash with the graph loaded by the API.
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, dependency-resolved Core/module
Alembic heads and never upgrades schema. Cross-module `depends_on` revisions
therefore do not leave runtime roles waiting for a branch marker Alembic has
correctly consumed. This permits a migration Job and runtime Deployments to be
submitted together while keeping startup fail-closed.
Runtime coordination records the installed `govoplan-core` distribution
version for API, worker and scheduler roles. FastAPI/OpenAPI metadata versions
are presentation metadata and must not be used as deployable software identity;
mixing the two would create a false version-skew readiness failure.
## 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/operations/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.
+21
View File
@@ -0,0 +1,21 @@
# Tabular Source Preview Contract
Core defines provider-neutral DTOs for optional tabular source providers. A
source declares whether it is live, cached, file-backed, or static; its schema
and immutable fingerprint; structured health; and the exact projection,
pagination, filter, aggregation, and sorting operations that the provider can
push down. Consumers must not infer pushdown support from a provider name.
Every preview request carries independent row, byte, and elapsed-time budgets.
A provider may tighten these values but must return its effective limits,
returned byte count, elapsed milliseconds, truncation state, and structured
diagnostics. Equivalent fields on the Datasources read request and result
preserve that evidence when a live source is consumed through the catalogue.
A row that cannot fit within the byte budget fails explicitly rather than
leaking a partial value. Timeout, stale fingerprint, unavailable source, and
authorization failures remain distinct provider-neutral errors.
Connector health and preview diagnostics must contain no credentials, endpoint
userinfo, row values, or unbounded remote error bodies. A Datasource origin
preserves this contract so registration and staging do not erase source mode,
health, pushdown, or preview-limit evidence.
+28
View File
@@ -0,0 +1,28 @@
# Template And Generated Artifact Capability Contracts
Core defines provider-neutral contracts for optional template libraries and
generated artifact storage. Core does not render templates or store generated
files itself.
## Templates
- `templates.catalog` lists typed, versioned template references and checks a
consumer's available fields, usage, and output format.
- `templates.renderer` accepts a `TemplateRenderRequest` containing pinned
input data and returns immutable render evidence plus an artifact reference.
The DTOs contain identifiers, hashes, plain mappings, and scalar metadata. They
do not expose Template ORM models or require Campaign, Distribution Lists,
Addresses, Reporting, Forms, or Mail.
## Generated Artifacts
`files.artifact_store` accepts a `ManagedArtifactWriteRequest` and returns a
`ManagedArtifactRef`. Producers supply bytes, a safe filename/content type,
idempotency key, and non-secret provenance. Files owns path normalization,
authorization, versions, storage, and download behavior.
Consumers must discover both contracts through the module registry and degrade
only the unavailable path. A template renderer may return a bounded download
when Files is absent. A caller must not infer successful external delivery from
successful rendering or artifact persistence.
+68
View File
@@ -0,0 +1,68 @@
# Temporal Data Context
GovOPlaN exposes one read context for data validity and system knowledge. The
calendar control in the authenticated titlebar applies that context to
supported list and detail reads for the current account and tenant.
## Two Independent Axes
- **Valid time** answers when a fact applied in the represented domain.
- **Recorded time** answers what the system had recorded by a particular
instant.
The default is data valid now under the latest recorded state. `At time`
selects a valid-time instant. `All` removes the valid-time interval filter but
still uses the selected recorded state. The optional recorded-state cutoff can
be combined with any valid-time mode, which keeps correction history distinct
from changes in real-world validity.
An interval is half open: `valid_from <= instant < valid_to`. A revision belongs
to a recorded-state snapshot when `recorded_at <= cutoff` and it was not
superseded at or before that cutoff.
## Security And Mutation Rules
The temporal data context is a read projection, not an authorization context.
Authentication, permissions, active delegations, tenant boundaries, module
policy, and maintenance controls are always evaluated under current security
state. A historical projection never restores an expired permission.
The context also does not supply mutation dates. Writes continue to target the
current lifecycle revision and must carry their explicit valid/effective dates,
expected revision, reason, and evidence where the owning contract requires
them. A screen showing historical data must not silently turn a normal edit
into a historical correction.
## HTTP Contract
Core accepts these request headers:
| Header | Meaning |
| --- | --- |
| `X-Govoplan-Validity-Mode` | `current`, `at`, or `all` |
| `X-Govoplan-Valid-At` | Timezone-aware ISO 8601 instant required by `at` |
| `X-Govoplan-Recorded-At` | Optional timezone-aware system-knowledge cutoff |
Invalid or naive timestamps fail with HTTP 400. Responses expose the resolved
mode and evaluated instant. Conditional JSON responses vary by all three
request headers, and the shared WebUI API client includes them in request
deduplication and conditional-cache keys.
## Module Adoption
Revision-owning modules apply
`govoplan_core.db.temporal.apply_temporal_revision_filter` only to read queries
that are meant to follow the platform context. Explicit version references and
explicit resolver `effective_at` arguments take precedence. Current-row
lookups used for optimistic concurrency, authorization, routing, effects, or
other mutations must remain explicit and context-independent.
The initial bitemporal adoption covers Decisions, Mandates, Parties, and
Services. Their immutable revisions have indexed valid, recorded, and
superseded timestamps. Modules with effective-dated security records or
recorded-only revision histories require separate display-query adoption so
the global selector cannot affect current authorization or execution.
The WebUI selection is stored in session storage per account and tenant. A
change remounts the active module route so existing page loaders issue a fresh
request. Returning both axes to their defaults removes the stored selection.
+56
View File
@@ -0,0 +1,56 @@
# 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`.
Each user may also choose a validated `default`, `civic_blue`, `forest`, or
`plum` accent palette. Core applies it through `data-palette`; every module
inherits the result through semantic tokens without module-specific CSS.
## 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.
- Palette defaults form a provenance chain: system, tenant, then an explicit
user choice. Invalid stored values are ignored. Reset means inheritance and
does not copy the current parent value into the child scope.
- A policy lock is separate from the default. A system lock wins over every
child scope; otherwise a tenant lock suppresses a personal override. The
authenticated profile reports the effective palette, source, inherited
palette, and lock state.
- Advanced personal overrides are a separately governed surface. The system
must opt in, a tenant may inherit or block that decision, and palette locks
always suppress overrides. Changing either policy requires
`admin:policies:write` in addition to the owning settings permission.
## Palette safety and scope
The Settings preview shows the chosen or inherited accent in every applicable
light/dark preview before Save. Presets are checked for WCAG AA contrast in the
theme contract. When policy permits, the shared advanced editor can atomically
override accent, surface, and semantic status pairs for both modes. Every
foreground/background pair must meet WCAG AA contrast, and success,
information, warning, and danger colors must remain distinct. Invalid stored
documents fail closed and are not partially applied.
Import and export use the exact versioned JSON schema `schema_version: "1"`.
Both `light` and `dark` must contain every supported token exactly once as a
six-digit hex value. Import changes only the local draft; Save persists the
whole document. Removing overrides returns to palette and policy inheritance.
The system default is disabled so upgrades do not unexpectedly admit arbitrary
branding. Tenant `null` means inherit, `false` blocks, and `true` is accepted
only while the system permits overrides.
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 root mode/palette behavior, preset and
custom-override validation/application, and representative
Campaign, Calendar, Files, and Mail token consumption. The check runs before a
production WebUI build.
+7 -4
View File
@@ -3,10 +3,13 @@
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, a missing Redis configuration, and temporary
Redis outages use a bounded process-local fallback. When Redis fails, local
attempts are still mirrored so losing the distributed store does not reset the
active worker's protection window.
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
+59
View File
@@ -0,0 +1,59 @@
# Ticket Integration Capability Contracts
Core owns two narrow, optional contracts that let the Tickets module compose
with policy and formal-procedure modules without importing either one. Tickets
remains the authority for operational ticket identity, lifecycle, assignment,
comments, links, and immutable history.
## Capability Names
- `tickets.routing` optionally supplies a `TicketRoutingProvider`.
- `tickets.case_escalation` optionally supplies a
`TicketCaseEscalationProvider`.
Both contracts are version 1 and are defined in
`govoplan_core.core.tickets`. Registry helpers return `None` when a capability
is absent or has the wrong shape, so optional-module absence is normal runtime
state rather than a startup failure.
## Routing
Tickets sends a bounded, tenant-scoped `TicketRoutingRequest` containing the
ticket reference, type, priority, title, receive time, optional queue hint, and
non-secret attributes. The provider returns its identity and may return a queue
reference, timezone-aware service target, human-readable explanation, and
bounded metadata.
The provider is advisory. Tickets snapshots any returned queue and target into
its own record and history. An absent provider, a no-match plan, or an absent
queue must not prevent ticket intake; authorized staff can route manually.
Providers must not persist a second ticket lifecycle.
## Case Escalation
Tickets sends a `TicketCaseEscalationCommand` with stable tenant, ticket, and
display references, the requested Case type, actor-visible handoff note,
timezone-aware occurrence time, and an idempotency key. The provider returns a
stable Case identifier, number, bounded application-relative URL, replay flag,
and bounded metadata.
Providers must:
- recheck tenant and Case-creation authorization;
- reject an absent or inactive requested Case type;
- make identical retries resolve the same Case;
- preserve the Ticket reference in governed Case context; and
- return only an application-relative path, never an untrusted external URL.
Tickets records the result and its own escalation evidence. Cases remains the
authority for the formal procedure; Tickets remains the authority for the
operational request. Creating a Case does not merge or silently close either
lifecycle.
## Failure And Transaction Semantics
Capability calls receive the caller's active persistence session so a concrete
provider can participate in the same unit of work. Authorization and validation
errors fail the requested routing/escalation mutation explicitly. The caller
must still apply its own permission checks, tenant boundary, replay protection,
and immutable evidence rules.
+60 -9
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
@@ -50,6 +50,15 @@ contestability, responsibility, and traceability at the point of action.
| 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 |
| UX-027 | The platform icon rail keeps its brand header and utility footer visible. Only the module-navigation region scrolls when installed and permitted modules exceed the available viewport height. | Accepted | Core WebUI shell |
| UX-028 | Maintenance and offline states use their established textual warning banners centered in the titlebar. They must not recolor the shell or add decorative status icons. Global search is a right-side command, so warnings do not replace its trigger or overlay. | Accepted | Core WebUI shell |
| UX-029 | Recoverable page and module errors use the central compact `DismissibleAlert` presentation with an explicit recovery action where one exists. Full-height workspaces must overlay page feedback instead of allowing an alert to become a stretched workspace row. | Accepted | Core and module WebUIs |
| UX-030 | At narrow widths, the titlebar uses separate context and command rows. Context selectors remain horizontally reachable, search retains its compact trigger, and language/help/notification/account commands remain fixed icon controls without overlap. Shared content padding contracts so domain workspaces retain usable width. | Accepted | Core WebUI shell and all module workspaces |
| UX-031 | Public controls and extension contributions use stable, module-namespaced interface identities. Shared controls expose `interfaceId` and `helpTopicId`; generated source anchors are inventory evidence, not a substitute for an explicit ID when documentation, policy, or automation refers to the control. | Accepted | Core and module WebUIs |
| UX-032 | `F1` resolves help from the focused field or action, then its dialog/section/page and registered route. Focused contexts retain the page fallback; Docs applies audience and permission filtering and falls back to visible module documentation. | Accepted | Core shell, Docs, and all module WebUIs |
| UX-033 | Global search is the left-most titlebar command, immediately before language selection. Its icon, `F3`, and `Ctrl`/`Cmd`+`K` all open the same permission-aware search overlay; the titlebar does not reserve a persistent query field. | Accepted | Core shell and Search WebUI |
| UX-034 | Every headed `PageLayout` declares one of `overview`, `collection`, `detail`, `editor`, or `workspace` independently from its standalone/workspace/embedded geometry. Its actions use the matching semantic `PageActionBar`: a refreshable page must provide Reload in the leading slot; collections keep Create far right; read-only pages do not invent Save. | Accepted | Core and all module WebUIs |
| UX-035 | Editor action bars expose clean, dirty, and saving state; always retain Discard immediately before the far-right Save; centrally disable both while clean or saving; and participate in the unsaved-change navigation guard. Danger actions occupy the explicit separated destructive group after ordinary actions and before editor persistence. | Accepted | Core and all module WebUIs |
## Confirmed Implementation Decisions
@@ -156,14 +165,19 @@ Decision: the WebUI shell exposes a small, stable appearance contract based on
shared CSS tokens and persisted user preference selection.
- Core applies `system`, `light`, and `dark` preferences at the document root.
- Core applies validated user accent presets through `data-palette`; palette
values change semantic tokens globally and never require module CSS changes.
- Core owns shared tokens such as `--bg`, `--bar`, `--panel`, `--surface`,
`--line`, `--line-dark`, `--text`, `--text-strong`, `--muted`, semantic
status colors, radii, shadows, and disabled-control colors.
- Modules must style new UI with these tokens and shared controls. Module-local
CSS may tune layout and spacing, but it must not introduce a separate
appearance system.
- Appearance controls live in user settings first. Tenant defaults and policy
enforcement can be added later without changing the token contract.
- Appearance controls live in user settings. A personal palette wins over
unlocked tenant and system defaults; system and tenant locks take precedence.
Advanced personal token overrides additionally require system opt-in and may
be narrowed by tenant policy. Their versioned import/export document is
validated and applied all-or-nothing in both light and dark modes.
- Visual preview in settings is illustrative; it must reflect token families,
not become a second theme implementation.
@@ -223,6 +237,10 @@ instead of reproducing their behavior.
- `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.
- Shared action-bearing components accept an optional disabled reason. In
particular, `MailServerSettingsPanel` forwards protocol-specific test
blockers into the shared focusable disabled-action tooltip; modules provide
the domain-specific required field, permission, or in-progress reason.
- A dirty editor registers once with `useUnsavedDraftGuard`. An explicit
Discard button calls `useUnsavedChanges().requestDiscard(afterResolve)`; SPA
navigation uses `useGuardedNavigate` or `requestNavigation`. Both paths show
@@ -233,9 +251,37 @@ instead of reproducing their behavior.
`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
@@ -244,7 +290,7 @@ 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. |
| `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. `PasswordField` may opt into the shared cryptographic generator; the candidate dialog is subordinate to the enclosing field and commits only through its explicit Use action. | 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. |
@@ -275,14 +321,14 @@ converted or reviewed.
| Surface | Repository | UX State | Next Action |
| --- | --- | --- | --- |
| File connector settings | `govoplan-files` | First adaptive modal slice started: connections and credentials now use full-state create/edit forms with conditional fields, advanced panels, and blocker primitives. Wizard shell is retained for later assisted setup. Central policy card still needs a layered editor. | Finish provider discovery/test-in-flow, then convert policy editing. |
| Mail server settings | `govoplan-mail` / `govoplan-core` | Uses the shared server/credential model visually, but create/edit still needs the same adaptive pattern as files. | Migrate to adaptive server/credential/policy dialogs, with optional assisted wizard later. |
| File connector settings | `govoplan-files` | Migrated to the shared adaptive server/credential/policy pattern with provider discovery, typed controls, actionable blockers, consequence-aware removal, and module-owned verification evidence. | Continue only through bounded Files-owned follow-ups. |
| Mail server settings | `govoplan-mail` / `govoplan-core` | Migrated to the same layered profile/server/credential/policy pattern, including focused connection tests, unsaved-state handling, contextual help, and permission/target blockers. | Continue only through bounded Mail-owned follow-ups. |
| Connector policy/effective rows | `govoplan-core`, module UIs | Effective-policy direction exists, but many editors still expose broad option sets. | Put effective value first, move overrides into modal, and explain blocked edits. |
| Admin module management | `govoplan-admin` | Has preflight concepts, but operational choices are still technical and dense. | Convert install/uninstall/package changes to operator wizards. |
| Configuration packages | `govoplan-admin` | Catalog/import work exists, but package editing can still drift toward technical fields. | Add guided import/review/problem-list flow. |
| Retention and privacy | `govoplan-core` | Functional editor exists; consequence language and provenance can be stronger. | Layer advanced retention options and add review for broad changes. |
| Retention and privacy | `govoplan-core` | Typed effective-policy editor exposes source paths, narrowing semantics, platform locks, permission/target blockers, and explicit clean/loading/save states. | Broader governed-change review remains module-owned where a policy change requires approval. |
| API keys | `govoplan-access` / admin UI | Security-sensitive creation needs least-privilege guidance. | Add scoped creation wizard with expiry/owner review. |
| User settings | `govoplan-core` | Preferences persistence exists; interface navigation issue was fixed earlier, but the surface still needs UX review. | Keep simple sections, remove double-click traps, and add quiet explanations. |
| User settings | `govoplan-core` | Simple typed sections use unsaved-change guards, quiet result feedback, contextual help, explicit busy/clean disabled-action reasons, and an effective appearance source. Palette selection and light/dark preview are shared with system and tenant administration. | Keep bounded; new contributed sections must satisfy the checklist. |
## Impact Index
@@ -298,7 +344,7 @@ converted or reviewed.
| Automation/workflow commands | Hidden side effects would undermine accountability. | Action/effect preview, system-actor display, command record, retry/quarantine/manual states, and audit links. |
| Postbox and encrypted communication | Retraction and access can be misunderstood. | Honest key-fetch/decryption state, expiry limits, recipient/device access provenance, and delivery evidence. |
| API keys | Security-sensitive creation and scope selection. | Scoped creation wizard, least-privilege suggestions, clear expiry/owner explanation. |
| User settings | Needs clarity and persistence across profile/interface/preferences. | Simple settings sections with immediate feedback and no double-click navigation traps. |
| User settings | Needs clarity and persistence across profile/interface/preferences. | Simple settings sections with immediate feedback, explicit inherit/reset semantics, effective-source provenance, and no double-click navigation traps. |
## Review Checklist
@@ -311,6 +357,11 @@ Every new or changed admin/configuration surface should answer:
- Does the screen explain disabled actions and failed validation in plain
language?
- Does it say who can fix a blocker and where?
- Does a module-localized blocker pass its translated row labels through the
shared `ActionBlockerHint` contract instead of reproducing the component?
- Does longer field or blocker guidance use a stable `DocumentationHelpLink`
topic/context reference, with hosted fallback when the optional Docs module
is absent?
- Does it reuse existing core patterns for wizard steps, problem lists, modals,
help, and review?
- Is there a review or preflight step before broad, destructive, or risky
+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.
+12
View File
@@ -0,0 +1,12 @@
# WebUI Module Package Layout
Core discovers a module contribution from `src/module.ts` when `node_modules`
links directly to a module's `webui` package. Tagged release dependencies are
installed from repository-root packages and expose the same contribution at
`webui/src/module.ts`. The Vite registry accepts both layouts and imports the
contribution descriptor directly so route-level lazy loading is preserved.
A release package is invalid if neither entry exists. The module-permutation CI
matrix builds source-linked and installed release compositions; it must not fall
back to a package root barrel because that would eagerly pull module pages into
the shell bundle.
@@ -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" },
File diff suppressed because it is too large Load Diff
+12 -12
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,9 +78,9 @@
"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",
@@ -97,14 +97,14 @@
],
"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",
@@ -121,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",
+11 -3
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-core"
version = "0.1.11"
version = "0.1.36"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md"
requires-python = ">=3.12"
@@ -15,21 +15,29 @@ dependencies = [
"fastapi>=0.139,<1",
"pydantic>=2,<3",
"pydantic-settings>=2,<3",
"cryptography>=48.0.1,<50",
"cryptography>=50.0.0,<51",
"celery>=5,<6",
"redis>=5,<6",
"alembic>=1,<2",
"boto3>=1.34,<2",
]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
govoplan_core = ["py.typed"]
govoplan_core = ["py.typed", "resources/*.json"]
[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"
govoplan-first-admin = "govoplan_core.commands.first_admin:main"
govoplan-module-install-plan = "govoplan_core.commands.module_install_plan:main"
govoplan-module-installer = "govoplan_core.commands.module_installer:main"
+1 -2
View File
@@ -12,7 +12,7 @@ class SystemSettings(Base, TimestampMixin):
__tablename__ = "core_system_settings"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default="global")
default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False)
default_locale: Mapped[str] = mapped_column(String(20), default="de", nullable=False)
allow_tenant_custom_groups: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
allow_tenant_custom_roles: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
allow_tenant_api_keys: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
@@ -20,4 +20,3 @@ class SystemSettings(Base, TimestampMixin):
__all__ = ["SystemSettings"]
+86 -5
View File
@@ -3,7 +3,9 @@ from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
from govoplan_core.core.appearance import normalize_appearance_overrides
class AuditLogItemResponse(BaseModel):
@@ -39,6 +41,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,12 +75,18 @@ 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
name: str
is_active: bool = True
default_locale: str = "en"
default_locale: str = "de"
enabled_language_codes: list[str] = Field(default_factory=list)
@@ -69,6 +95,45 @@ class TenantMembershipInfo(TenantInfo):
is_active: bool = True
class NavigationPreferencesPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
contract_version: Literal["1"] = "1"
order: list[str] = Field(default_factory=list, max_length=256)
hidden: list[str] = Field(default_factory=list, max_length=256)
locked: list[str] = Field(default_factory=list, max_length=256)
class AppearanceModeOverrides(BaseModel):
model_config = ConfigDict(extra="forbid")
accent: str
accent_foreground: str
surface: str
surface_foreground: str
success: str
success_foreground: str
info: str
info_foreground: str
warning: str
warning_foreground: str
danger: str
danger_foreground: str
class AppearanceOverridesDocument(BaseModel):
model_config = ConfigDict(extra="forbid")
schema_version: Literal["1"] = "1"
light: AppearanceModeOverrides
dark: AppearanceModeOverrides
@model_validator(mode="after")
def validate_accessibility(self) -> "AppearanceOverridesDocument":
normalize_appearance_overrides(self.model_dump(mode="json"))
return self
class UserUiPreferences(BaseModel):
model_config = ConfigDict(extra="ignore")
@@ -77,6 +142,20 @@ class UserUiPreferences(BaseModel):
reduce_motion: bool = False
sticky_section_sidebars: bool = True
theme: Literal["system", "light", "dark"] = "system"
palette: Literal["default", "civic_blue", "forest", "plum"] | None = None
appearance_overrides: AppearanceOverridesDocument | None = None
navigation: NavigationPreferencesPayload | None = None
class EffectiveAppearanceInfo(BaseModel):
palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
source: Literal["user", "tenant", "system", "tenant_lock", "system_lock"] = "system"
locked: bool = False
system_default_palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
tenant_default_palette: Literal["default", "civic_blue", "forest", "plum"] | None = None
inherited_palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
custom_overrides: AppearanceOverridesDocument | None = None
custom_overrides_allowed: bool = False
class UserInfo(BaseModel):
@@ -92,6 +171,7 @@ class UserInfo(BaseModel):
preferred_language: str | None = None
enabled_language_codes: list[str] = Field(default_factory=list)
ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences)
appearance: EffectiveAppearanceInfo = Field(default_factory=EffectiveAppearanceInfo)
class AuthSessionUserInfo(BaseModel):
@@ -160,6 +240,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
@@ -185,7 +266,7 @@ class AuthProfileResponse(BaseModel):
active_tenant: TenantInfo
available_languages: list[LanguageInfo] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list)
default_language: str = "en"
default_language: str = "de"
profile_loaded: bool = True
@@ -214,7 +295,7 @@ class LoginResponse(BaseModel):
principal: PrincipalContextInfo | None = None
available_languages: list[LanguageInfo] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list)
default_language: str = "en"
default_language: str = "de"
profile_loaded: bool = True
roles_loaded: bool = True
groups_loaded: bool = True
@@ -232,7 +313,7 @@ class MeResponse(BaseModel):
principal: PrincipalContextInfo | None = None
available_languages: list[LanguageInfo] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list)
default_language: str = "en"
default_language: str = "de"
profile_loaded: bool = True
roles_loaded: bool = True
groups_loaded: bool = True
+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,
)
+9 -1
View File
@@ -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")
@@ -131,6 +135,9 @@ def get_api_principal(
authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
) -> ApiPrincipal:
cached = getattr(request.state, "govoplan_api_principal", None)
if isinstance(cached, ApiPrincipal):
return cached
principal = _api_principal_provider_from_request(request).resolve_api_principal(
request,
session,
@@ -139,6 +146,7 @@ def get_api_principal(
)
if not isinstance(principal, ApiPrincipal):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid API principal")
request.state.govoplan_api_principal = principal
return principal
File diff suppressed because it is too large Load Diff
+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())
+233
View File
@@ -0,0 +1,233 @@
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import stat
from typing import Any
from govoplan_core.core.access import (
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER,
FirstAdminProvisioner,
)
from govoplan_core.core.first_admin import (
FirstAdminEnrollmentError,
first_admin_enrollment_status,
issue_first_admin_credential,
)
from govoplan_core.core.module_management import (
load_startup_enabled_modules,
startup_candidate_module_ids,
)
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.runtime import configure_runtime
from govoplan_core.db.session import configure_database, get_database
from govoplan_core.server.registry import (
available_module_manifests,
build_platform_registry,
)
from govoplan_core.settings import settings
def main() -> None:
parser = argparse.ArgumentParser(
description="Manage the single-use production first-administrator credential",
)
parser.add_argument(
"command",
choices=("status", "issue", "recover"),
help="Inspect readiness, issue the initial credential, or rotate lost/expired material.",
)
parser.add_argument("--database-url", default=settings.database_url)
parser.add_argument("--installation-id", default=settings.installation_id)
parser.add_argument(
"--output",
type=Path,
default=Path(settings.first_admin_enrollment_file),
help="Root-readable/equivalent JSON credential artifact.",
)
parser.add_argument(
"--ttl-seconds",
type=int,
default=settings.first_admin_enrollment_ttl_seconds,
)
parser.add_argument(
"--reason",
default=None,
help="Audited local-operator reason for issue or recovery.",
)
args = parser.parse_args()
configure_database(args.database_url)
provisioner = _configure_first_admin_provisioner()
with get_database().SessionLocal() as session:
if args.command == "status":
enrollment = first_admin_enrollment_status(
session,
installation_id=args.installation_id,
provisioner=provisioner,
)
print(
json.dumps(
{
"installation_id": args.installation_id,
"enrollment_required": enrollment.enrollment_required,
"credential_active": enrollment.credential_active,
"state": enrollment.state,
"generation": enrollment.generation,
"expires_at": (
enrollment.expires_at.isoformat()
if enrollment.expires_at is not None
else None
),
"readiness": enrollment.readiness,
},
indent=2,
sort_keys=True,
)
)
return
reason = args.reason or (
"initial production administrator enrollment"
if args.command == "issue"
else "local operator recovery of first-administrator enrollment"
)
try:
credential = issue_first_admin_credential(
session,
installation_id=args.installation_id,
provisioner=provisioner,
ttl_seconds=args.ttl_seconds,
reason=reason,
replace_active=args.command == "recover",
)
payload = {
"schema_version": 1,
"installation_id": args.installation_id,
"endpoint": "/api/v1/bootstrap/first-admin",
"header": "X-GovOPlaN-Enrollment-Token",
"enrollment_token": credential.secret,
"fingerprint": credential.fingerprint,
"generation": credential.generation,
"expires_at": credential.expires_at.isoformat(),
}
previous = _secure_file_snapshot(args.output)
_write_private_json(args.output, payload)
try:
session.commit()
except Exception:
session.rollback()
_restore_secure_file(args.output, previous)
raise
except FirstAdminEnrollmentError as exc:
session.rollback()
parser.error(str(exc))
print(f"First-administrator credential written to {args.output}")
print(f"Fingerprint: {credential.fingerprint}")
print(f"Expires: {credential.expires_at.isoformat()}")
print("The secret was not printed. Read it from the restricted artifact on the host.")
def _configure_first_admin_provisioner() -> FirstAdminProvisioner:
raw_enabled = load_startup_enabled_modules(settings.enabled_modules)
candidates = startup_candidate_module_ids(settings.enabled_modules, raw_enabled)
available = available_module_manifests(
enabled_modules=candidates,
ignore_load_errors=True,
)
enabled = load_startup_enabled_modules(
settings.enabled_modules,
available=available,
)
registry = build_platform_registry(enabled)
context = ModuleContext(registry=registry, settings=settings)
registry.configure_capability_context(context)
configure_runtime(context)
if not registry.has_capability(CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER):
raise RuntimeError(
"Install and enable the Access module before issuing a first-administrator credential."
)
capability = registry.require_capability(CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER)
if not isinstance(capability, FirstAdminProvisioner):
raise RuntimeError("The Access first-administrator capability is invalid.")
return capability
def _secure_file_snapshot(path: Path) -> tuple[bytes, int] | None:
try:
metadata = path.lstat()
except FileNotFoundError:
return None
if not stat.S_ISREG(metadata.st_mode):
raise RuntimeError(f"Refusing to replace non-regular credential artifact: {path}")
if metadata.st_uid != os.geteuid():
raise RuntimeError(f"Credential artifact is not owned by the current operator: {path}")
if stat.S_IMODE(metadata.st_mode) & 0o077:
raise RuntimeError(f"Credential artifact permissions are too broad: {path}")
return path.read_bytes(), stat.S_IMODE(metadata.st_mode)
def _write_private_json(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
descriptor = os.open(temporary, flags, 0o600)
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
json.dump(payload, stream, indent=2, sort_keys=True)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
os.chmod(path, 0o600)
_fsync_directory(path.parent)
except Exception:
try:
temporary.unlink()
except FileNotFoundError:
pass
raise
def _restore_secure_file(path: Path, snapshot: tuple[bytes, int] | None) -> None:
if snapshot is None:
try:
path.unlink()
except FileNotFoundError:
return
return
content, mode = snapshot
temporary = path.with_name(f".{path.name}.{os.getpid()}.restore")
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
os.chmod(path, mode)
_fsync_directory(path.parent)
finally:
try:
temporary.unlink()
except FileNotFoundError:
pass
def _fsync_directory(path: Path) -> None:
if not hasattr(os, "O_DIRECTORY"):
return
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
if __name__ == "__main__":
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)
@@ -1,6 +1,7 @@
from __future__ import annotations
import argparse
from importlib.metadata import PackageNotFoundError, version
import json
from pathlib import Path
import sys
@@ -33,6 +34,10 @@ from govoplan_core.core.module_installer_notifications import (
installer_notification_priority,
installer_notification_subject,
)
from govoplan_core.core.runtime_coordination import (
bind_process_runtime_identity,
runtime_identity,
)
from govoplan_core.core.module_license import issue_module_license, module_license_diagnostics
from govoplan_core.core.module_package_catalog import sign_module_package_catalog, validate_module_package_catalog
from govoplan_core.core.module_management import (
@@ -107,11 +112,27 @@ def _build_parser() -> argparse.ArgumentParser:
def main() -> int:
args = _build_parser().parse_args()
runtime_dir = args.runtime_dir or default_installer_runtime_dir(args.database_url)
bind_process_runtime_identity(
runtime_identity(
settings,
software_version=_core_version(),
role="installer",
)
)
try:
return _dispatch_command(args=args, runtime_dir=runtime_dir)
except ModuleInstallerError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
finally:
bind_process_runtime_identity(None)
def _core_version() -> str:
try:
return version("govoplan-core")
except PackageNotFoundError:
return "development"
def _dispatch_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
@@ -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())
+164
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"
@@ -20,14 +21,22 @@ CAPABILITY_ACCESS_RESOURCE_ACCESS = "access.resourceAccess"
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY = "access.semanticDirectory"
CAPABILITY_ACCESS_EXPLANATION = "access.explanation"
CAPABILITY_ACCESS_TENANT_PROVISIONER = "access.tenantProvisioner"
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER = "access.firstAdminProvisioner"
CAPABILITY_ACCESS_ADMINISTRATION = "access.administration"
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER = "access.governanceMaterializer"
CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1 = "access.governanceProjection.v1"
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS = (
"policy.access_explanation_subjects"
)
CAPABILITY_TENANCY_TENANT_RESOLVER = "tenancy.tenantResolver"
CAPABILITY_AUDIT_SINK = "audit.sink"
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"
@@ -41,13 +50,16 @@ ACCESS_CAPABILITY_NAMES = frozenset(
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
CAPABILITY_ACCESS_EXPLANATION,
CAPABILITY_ACCESS_TENANT_PROVISIONER,
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER,
CAPABILITY_ACCESS_ADMINISTRATION,
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1,
CAPABILITY_TENANCY_TENANT_RESOLVER,
CAPABILITY_AUDIT_SINK,
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 +69,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 +133,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 +153,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,12 +175,22 @@ 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")),
)
@dataclass(frozen=True, slots=True)
class AccessExplanationSubjectDecision:
allow_other_users: bool
reason: str
source: str
required_scope: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
def _optional_str(value: object | None) -> str | None:
return str(value) if value is not None else None
@@ -327,6 +358,19 @@ class DevelopmentBootstrapRef:
created_api_key: CreatedApiKeyRef | None = None
@dataclass(frozen=True, slots=True)
class FirstSystemAdministratorRef:
account_id: str
email: str
display_name: str | None = None
membership_id: str | None = None
tenant_id: str | None = None
class FirstAdminProvisioningError(RuntimeError):
"""Safe, user-facing rejection from the Access enrollment boundary."""
@dataclass(frozen=True, slots=True)
class TenantContextSwitchRef:
account_id: str
@@ -348,6 +392,82 @@ class GovernanceTemplateMaterialization:
required: bool = False
GovernanceProjectionOperation = Literal["upsert", "remove"]
GovernanceProjectionStatus = Literal[
"created",
"updated",
"unchanged",
"removed",
"absent",
"blocked",
"failed",
]
@dataclass(frozen=True, slots=True)
class GovernanceProjectionCommand:
"""Stable Access-owned input for one governance assignment projection."""
assignment_id: str
operation: GovernanceProjectionOperation
template: GovernanceTemplateMaterialization
provenance: Mapping[str, str] = field(default_factory=dict)
def __post_init__(self) -> None:
if not self.assignment_id or len(self.assignment_id) > 255:
raise ValueError("Governance projection assignment ids must contain at most 255 characters.")
if len(self.provenance) > 20:
raise ValueError("Governance projection provenance supports at most 20 entries.")
for key, value in self.provenance.items():
if not key or len(key) > 100 or len(value) > 500:
raise ValueError("Governance projection provenance entries exceed their bounds.")
@dataclass(frozen=True, slots=True)
class GovernanceProjectionBatch:
"""Versioned, bounded reconciliation request independent of Admin internals."""
operation_id: str
commands: tuple[GovernanceProjectionCommand, ...]
version: Literal["1"] = "1"
dry_run: bool = False
def __post_init__(self) -> None:
if not self.operation_id or len(self.operation_id) > 255:
raise ValueError("Governance projection operation ids must contain at most 255 characters.")
if not self.commands or len(self.commands) > 500:
raise ValueError("Governance projection batches must contain between 1 and 500 commands.")
assignment_ids = [command.assignment_id for command in self.commands]
if len(assignment_ids) != len(set(assignment_ids)):
raise ValueError("Governance projection assignment ids must be unique within a batch.")
@dataclass(frozen=True, slots=True)
class GovernanceProjectionOutcome:
assignment_id: str
template_id: str
tenant_id: str
kind: Literal["group", "role"]
operation: GovernanceProjectionOperation
status: GovernanceProjectionStatus
resource_id: str | None = None
blocker_codes: tuple[str, ...] = ()
message: str | None = None
provenance: Mapping[str, str] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class GovernanceProjectionResult:
operation_id: str
outcomes: tuple[GovernanceProjectionOutcome, ...]
version: Literal["1"] = "1"
dry_run: bool = False
@property
def blocked(self) -> tuple[GovernanceProjectionOutcome, ...]:
return tuple(item for item in self.outcomes if item.status in {"blocked", "failed"})
@dataclass(frozen=True, slots=True)
class AuditEvent:
event_type: str
@@ -363,6 +483,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)
@@ -533,6 +654,18 @@ class AccessExplanationService(Protocol):
...
@runtime_checkable
class AccessExplanationSubjectPolicy(Protocol):
def decide_subject_selection(
self,
session: object,
principal: PrincipalRef,
*,
tenant_id: str,
) -> AccessExplanationSubjectDecision:
...
@runtime_checkable
class TenantAccessProvisioner(Protocol):
def ensure_default_roles(self, session: object, tenant: object | None = None) -> Mapping[str, object]:
@@ -563,6 +696,25 @@ class TenantAccessProvisioner(Protocol):
...
@runtime_checkable
class FirstAdminProvisioner(Protocol):
"""Narrow Access boundary used only by the production bootstrap flow."""
def has_durable_system_administrator(self, session: object) -> bool:
...
def create_first_system_administrator(
self,
session: object,
*,
tenant: object,
email: str,
display_name: str | None,
password: str,
) -> FirstSystemAdministratorRef:
...
@runtime_checkable
class AccessAdministration(Protocol):
def tenant_counts(self, session: object, tenant_id: str) -> Mapping[str, int]:
@@ -619,6 +771,18 @@ class AccessGovernanceMaterializer(Protocol):
...
@runtime_checkable
class AccessGovernanceProjectionV1(Protocol):
"""Bulk reconciliation boundary for Admin-owned governance assignments."""
def reconcile(
self,
session: object,
batch: GovernanceProjectionBatch,
) -> GovernanceProjectionResult:
...
@runtime_checkable
class AuditSink(Protocol):
def record(self, event: AuditEvent) -> None:
+223
View File
@@ -0,0 +1,223 @@
from __future__ import annotations
from dataclasses import dataclass
import re
from typing import Any, Literal, Mapping
AppearancePalette = Literal["default", "civic_blue", "forest", "plum"]
AppearanceSource = Literal["user", "tenant", "system", "tenant_lock", "system_lock"]
APPEARANCE_PALETTES: tuple[AppearancePalette, ...] = ("default", "civic_blue", "forest", "plum")
APPEARANCE_SETTINGS_KEY = "appearance"
APPEARANCE_OVERRIDE_SCHEMA_VERSION = "1"
APPEARANCE_OVERRIDE_TOKENS: tuple[str, ...] = (
"accent", "accent_foreground", "surface", "surface_foreground",
"success", "success_foreground", "info", "info_foreground",
"warning", "warning_foreground", "danger", "danger_foreground",
)
_STATUS_TOKENS = ("success", "info", "warning", "danger")
_HEX_COLOR = re.compile(r"^#[0-9a-fA-F]{6}$")
@dataclass(frozen=True, slots=True)
class EffectiveAppearance:
palette: AppearancePalette
source: AppearanceSource
locked: bool
system_default_palette: AppearancePalette
tenant_default_palette: AppearancePalette | None
inherited_palette: AppearancePalette
custom_overrides: dict[str, object] | None = None
custom_overrides_allowed: bool = False
def as_dict(self) -> dict[str, object]:
return {
"palette": self.palette,
"source": self.source,
"locked": self.locked,
"system_default_palette": self.system_default_palette,
"tenant_default_palette": self.tenant_default_palette,
"inherited_palette": self.inherited_palette,
"custom_overrides": self.custom_overrides,
"custom_overrides_allowed": self.custom_overrides_allowed,
}
def normalize_appearance_palette(value: object, *, fallback: AppearancePalette | None = None) -> AppearancePalette | None:
normalized = str(value or "").strip().lower()
return normalized if normalized in APPEARANCE_PALETTES else fallback # type: ignore[return-value]
def appearance_settings(settings: Mapping[str, Any] | None) -> tuple[AppearancePalette | None, bool]:
raw = settings.get(APPEARANCE_SETTINGS_KEY) if isinstance(settings, Mapping) else None
if not isinstance(raw, Mapping):
return None, False
return normalize_appearance_palette(raw.get("default_palette")), raw.get("palette_locked") is True
def appearance_custom_overrides_policy(settings: Mapping[str, Any] | None) -> bool | None:
raw = settings.get(APPEARANCE_SETTINGS_KEY) if isinstance(settings, Mapping) else None
if not isinstance(raw, Mapping) or "allow_custom_overrides" not in raw:
return None
return raw.get("allow_custom_overrides") is True
def update_appearance_custom_overrides_policy(
settings: Mapping[str, Any] | None,
*,
allowed: bool | None,
) -> dict[str, Any]:
updated = dict(settings or {})
appearance = dict(updated.get(APPEARANCE_SETTINGS_KEY) or {}) if isinstance(updated.get(APPEARANCE_SETTINGS_KEY), Mapping) else {}
if allowed is None:
appearance.pop("allow_custom_overrides", None)
else:
appearance["allow_custom_overrides"] = allowed
if appearance:
updated[APPEARANCE_SETTINGS_KEY] = appearance
else:
updated.pop(APPEARANCE_SETTINGS_KEY, None)
return updated
def normalize_appearance_overrides(value: object) -> dict[str, object] | None:
"""Validate and canonicalize the versioned, all-or-nothing color contract."""
if value is None:
return None
if not isinstance(value, Mapping):
raise ValueError("Appearance overrides must be an object.")
if set(value) != {"schema_version", "light", "dark"}:
raise ValueError("Appearance overrides must contain only schema_version, light, and dark.")
if str(value.get("schema_version")) != APPEARANCE_OVERRIDE_SCHEMA_VERSION:
raise ValueError("Unsupported appearance override schema version.")
normalized: dict[str, object] = {"schema_version": APPEARANCE_OVERRIDE_SCHEMA_VERSION}
for mode in ("light", "dark"):
raw_mode = value.get(mode)
if not isinstance(raw_mode, Mapping) or set(raw_mode) != set(APPEARANCE_OVERRIDE_TOKENS):
raise ValueError(f"Appearance override mode {mode} must define every supported token exactly once.")
colors: dict[str, str] = {}
for token in APPEARANCE_OVERRIDE_TOKENS:
color = str(raw_mode.get(token) or "").strip().lower()
if not _HEX_COLOR.fullmatch(color):
raise ValueError(f"Appearance override {mode}.{token} must be a six-digit hexadecimal color.")
colors[token] = color
_validate_mode_accessibility(mode, colors)
normalized[mode] = colors
return normalized
def _validate_mode_accessibility(mode: str, colors: Mapping[str, str]) -> None:
pairs = (
("accent", "accent_foreground"), ("surface", "surface_foreground"),
("success", "success_foreground"), ("info", "info_foreground"),
("warning", "warning_foreground"), ("danger", "danger_foreground"),
)
for background, foreground in pairs:
if _contrast_ratio(colors[background], colors[foreground]) < 4.5:
raise ValueError(f"Appearance override {mode}.{foreground} must have WCAG AA contrast against {mode}.{background}.")
status_colors = [colors[token] for token in _STATUS_TOKENS]
for index, first in enumerate(status_colors):
for second in status_colors[index + 1:]:
if _rgb_distance(first, second) < 12:
raise ValueError(f"Appearance override status colors in {mode} must remain visibly distinct.")
def _relative_luminance(color: str) -> float:
channels = [int(color[index:index + 2], 16) / 255 for index in (1, 3, 5)]
linear = [channel / 12.92 if channel <= 0.04045 else ((channel + 0.055) / 1.055) ** 2.4 for channel in channels]
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
def _contrast_ratio(first: str, second: str) -> float:
high, low = sorted((_relative_luminance(first), _relative_luminance(second)), reverse=True)
return (high + 0.05) / (low + 0.05)
def _rgb_distance(first: str, second: str) -> float:
first_channels = [int(first[index:index + 2], 16) for index in (1, 3, 5)]
second_channels = [int(second[index:index + 2], 16) for index in (1, 3, 5)]
return sum((left - right) ** 2 for left, right in zip(first_channels, second_channels, strict=True)) ** 0.5
def update_appearance_settings(
settings: Mapping[str, Any] | None,
*,
default_palette: AppearancePalette | None,
palette_locked: bool,
) -> dict[str, Any]:
updated = dict(settings or {})
appearance = dict(updated.get(APPEARANCE_SETTINGS_KEY) or {}) if isinstance(updated.get(APPEARANCE_SETTINGS_KEY), Mapping) else {}
if default_palette is None:
appearance.pop("default_palette", None)
else:
normalized = normalize_appearance_palette(default_palette)
if normalized is None:
raise ValueError("Unsupported appearance palette.")
appearance["default_palette"] = normalized
if palette_locked:
appearance["palette_locked"] = True
else:
appearance.pop("palette_locked", None)
if appearance:
updated[APPEARANCE_SETTINGS_KEY] = appearance
else:
updated.pop(APPEARANCE_SETTINGS_KEY, None)
return updated
def resolve_effective_appearance(
*,
system_settings: Mapping[str, Any] | None,
tenant_settings: Mapping[str, Any] | None,
user_settings: Mapping[str, Any] | None,
) -> EffectiveAppearance:
system_palette, system_locked = appearance_settings(system_settings)
system_palette = system_palette or "default"
tenant_palette, tenant_locked = appearance_settings(tenant_settings)
inherited_palette = tenant_palette or system_palette
raw_ui = user_settings.get("ui") if isinstance(user_settings, Mapping) else None
user_palette = normalize_appearance_palette(raw_ui.get("palette")) if isinstance(raw_ui, Mapping) else None
system_custom_policy = appearance_custom_overrides_policy(system_settings) is True
tenant_custom_policy = appearance_custom_overrides_policy(tenant_settings)
custom_overrides_allowed = system_custom_policy and tenant_custom_policy is not False and not system_locked and not tenant_locked
try:
custom_overrides = normalize_appearance_overrides(raw_ui.get("appearance_overrides")) if isinstance(raw_ui, Mapping) else None
except ValueError:
custom_overrides = None
if not custom_overrides_allowed:
custom_overrides = None
if system_locked:
return EffectiveAppearance(system_palette, "system_lock", True, system_palette, tenant_palette, system_palette)
if tenant_locked:
return EffectiveAppearance(inherited_palette, "tenant_lock", True, system_palette, tenant_palette, inherited_palette)
return EffectiveAppearance(
user_palette or inherited_palette,
"user" if user_palette else "tenant" if tenant_palette else "system",
False,
system_palette,
tenant_palette,
inherited_palette,
custom_overrides,
custom_overrides_allowed,
)
__all__ = [
"APPEARANCE_PALETTES",
"APPEARANCE_SETTINGS_KEY",
"APPEARANCE_OVERRIDE_SCHEMA_VERSION",
"APPEARANCE_OVERRIDE_TOKENS",
"AppearancePalette",
"AppearanceSource",
"EffectiveAppearance",
"appearance_settings",
"appearance_custom_overrides_policy",
"normalize_appearance_overrides",
"normalize_appearance_palette",
"resolve_effective_appearance",
"update_appearance_settings",
"update_appearance_custom_overrides_policy",
]
@@ -0,0 +1,81 @@
from __future__ import annotations
from collections.abc import Mapping
from datetime import datetime
from typing import Protocol, runtime_checkable
CAPABILITY_APPLICATION_STATUS_PROJECTION = "application_status.projection"
@runtime_checkable
class ApplicationStatusProjectionProvider(Protocol):
"""Bounded applicant-status access without exposing the owning module's data."""
def tenant_id_for_tracking_id(
self,
session: object,
*,
tracking_id: str,
) -> str | None:
...
def public_access_challenge(
self,
session: object,
*,
tracking_id: str,
) -> Mapping[str, object]:
...
def get_authenticated_projection(
self,
session: object,
principal: object,
*,
tracking_id: str,
observed_at: datetime,
) -> Mapping[str, object]:
...
def get_public_projection(
self,
session: object,
*,
tracking_id: str,
token: str | None,
observed_at: datetime,
) -> Mapping[str, object]:
...
def request_email_link(
self,
session: object,
*,
tracking_id: str,
email: str,
requested_at: datetime,
) -> bool:
...
def application_status_projection_provider(
registry: object | None,
) -> ApplicationStatusProjectionProvider | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_APPLICATION_STATUS_PROJECTION):
return None
capability = registry.capability(CAPABILITY_APPLICATION_STATUS_PROJECTION)
return (
capability
if isinstance(capability, ApplicationStatusProjectionProvider)
else None
)
__all__ = [
"ApplicationStatusProjectionProvider",
"CAPABILITY_APPLICATION_STATUS_PROJECTION",
"application_status_projection_provider",
]
+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",
]
+419
View File
@@ -0,0 +1,419 @@
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",
]
ActionRecoveryMode = Literal[
"atomic",
"compensation",
"snapshot_restore",
"forward_recovery",
"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
recovery_mode: ActionRecoveryMode = "forward_recovery"
recovery_verification: tuple[str, ...] = (
"verify the provider result and every announced effect before continuation",
)
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 self.recovery_mode not in {
"atomic",
"compensation",
"snapshot_restore",
"forward_recovery",
"irreversible",
}:
raise ValueError("Action recovery mode is not supported")
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")
if not self.recovery_verification or any(
not value.strip() for value in self.recovery_verification
):
raise ValueError("Actions must declare recovery verification steps")
@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",
"ActionRecoveryMode",
"ActionReversibility",
"ActionRiskLevel",
"EffectDefinition",
"EffectOperation",
"EffectPreview",
"ObservedEffect",
"action_effect_provider",
"automation_principal_provider",
]
+248
View File
@@ -8,6 +8,8 @@ from typing import Protocol, runtime_checkable
CAPABILITY_CALENDAR_SCHEDULING = "calendar.scheduling"
CAPABILITY_CALENDAR_OUTBOX = "calendar.outbox"
CAPABILITY_CALENDAR_INVITATIONS = "calendar.invitations"
CAPABILITY_CALENDAR_EXTERNAL_PROFILES = "calendar.externalProfiles"
CALENDAR_AVAILABILITY_READ_SCOPE = "calendar:availability:read"
CALENDAR_EVENT_WRITE_SCOPE = "calendar:event:write"
@@ -43,6 +45,103 @@ class CalendarEventRef:
outbox_operation_id: str | None = None
@dataclass(frozen=True, slots=True)
class CalendarEventReleaseRef:
event_id: str
accepted: bool = True
already_released: bool = False
external_state: str = "local_released"
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, ...] = ()
@dataclass(frozen=True, slots=True)
class CalendarInvitationCalendarRef:
id: str
name: str
color: str | None = None
timezone: str = "UTC"
source_kind: str = "local"
writable: bool = True
@dataclass(frozen=True, slots=True)
class CalendarExternalProfileRequest:
"""Connector-neutral request for a Calendar-owned external profile."""
profile_kind: str
calendar_id: str
endpoint_url: str
display_name: str | None = None
auth_type: str = "none"
username: str | None = None
credential_ref: str | None = None
sync_enabled: bool = True
sync_interval_seconds: int = 900
sync_direction: str = "two_way"
conflict_policy: str = "etag"
connector_profile_ref: str | None = None
identity_mapping_ref: str | None = None
resource_calendar_ref: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class CalendarExternalProfileRef:
source_id: str
calendar_id: str
profile_kind: str
transport_kind: str
connector_profile_ref: str | None = None
identity_mapping_ref: str | None = None
resource_calendar_ref: str | None = None
@runtime_checkable
class CalendarSchedulingProvider(Protocol):
def list_freebusy(
@@ -66,6 +165,27 @@ class CalendarSchedulingProvider(Protocol):
) -> CalendarEventRef:
...
def promote_event(
self,
session: object,
*,
tenant_id: str,
user_id: str | None,
event_id: str,
request: CalendarEventRequest,
) -> CalendarEventRef:
...
def release_event(
self,
session: object,
*,
tenant_id: str,
user_id: str | None,
event_id: str,
) -> CalendarEventReleaseRef:
...
@runtime_checkable
class CalendarOutboxProvider(Protocol):
@@ -80,6 +200,108 @@ class CalendarOutboxProvider(Protocol):
) -> Mapping[str, object]:
...
@runtime_checkable
class CalendarInvitationProvider(Protocol):
"""Correlation-aware invitation boundary for Campaign and Mail adapters."""
def list_calendars(
self,
session: object,
*,
tenant_id: str,
user_id: str | None = None,
group_ids: Sequence[str] = (),
can_admin: bool = False,
) -> Sequence[CalendarInvitationCalendarRef]:
...
def render_invitation(self, request: CalendarInvitationRequest) -> str:
...
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 get_invitations(
self,
session: object,
*,
tenant_id: str,
correlation_ids: Sequence[str],
) -> Mapping[str, CalendarInvitationRef]:
...
def summarize_invitations(
self,
session: object,
*,
tenant_id: str,
source_module: str,
source_resource_type: str,
source_resource_id: str | None,
) -> Mapping[str, object]:
...
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 record_icalendar_reply(
self,
session: object,
*,
tenant_id: str,
icalendar: str,
received_at: datetime | None = None,
evidence: Mapping[str, object] | None = None,
) -> Sequence[CalendarInvitationRef]:
...
@runtime_checkable
class CalendarExternalProfileProvider(Protocol):
"""Optional connector route for Calendar-owned groupware adapters."""
def supported_profiles(self) -> Sequence[Mapping[str, object]]:
...
def configure_profile(
self,
session: object,
*,
tenant_id: str,
user_id: str | None,
request: CalendarExternalProfileRequest,
) -> CalendarExternalProfileRef:
...
def calendar_scheduling_provider(registry: object | None) -> CalendarSchedulingProvider | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
@@ -96,3 +318,29 @@ 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
def calendar_external_profile_provider(
registry: object | None,
) -> CalendarExternalProfileProvider | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_CALENDAR_EXTERNAL_PROFILES):
return None
capability = registry.capability(CAPABILITY_CALENDAR_EXTERNAL_PROFILES)
return (
capability
if isinstance(capability, CalendarExternalProfileProvider)
else None
)
+158 -1
View File
@@ -3,14 +3,29 @@ from __future__ import annotations
from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
from typing import Literal, Protocol, runtime_checkable
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT = "campaigns.mailPolicyContext"
CAPABILITY_CAMPAIGNS_ACCESS = "campaigns.access"
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT = "campaigns.policyContext"
CAPABILITY_CAMPAIGNS_DELIVERY_TASKS = "campaigns.deliveryTasks"
CAPABILITY_CAMPAIGNS_SCHEDULES = "campaigns.schedules"
CAPABILITY_CAMPAIGNS_RETENTION = "campaigns.retention"
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION = "campaigns.workOrchestration"
CampaignWorkAssigneeKind = Literal[
"account",
"group",
"organization_function",
]
CampaignWorkHandoffStatus = Literal[
"open",
"in_progress",
"completed",
"rejected",
"cancelled",
]
@dataclass(frozen=True, slots=True)
@@ -31,6 +46,88 @@ class CampaignPolicyContext:
settings: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class CampaignWorkHandoffRequest:
"""Typed request used by Workflow to open accountable Campaign work."""
tenant_id: str
idempotency_key: str
purpose: str
assignee_kind: CampaignWorkAssigneeKind
assignee_id: str
campaign_id: str | None = None
create_external_id: str | None = None
create_name: str | None = None
create_description: str | None = None
expected_campaign_revision: int | None = None
due_at: datetime | None = None
mirror_to_tasks: bool = True
correlation_id: str | None = None
workflow_instance_id: str | None = None
workflow_step_id: str | None = None
def __post_init__(self) -> None:
for value, label in (
(self.tenant_id, "Campaign hand-off tenant"),
(self.idempotency_key, "Campaign hand-off idempotency key"),
(self.purpose, "Campaign hand-off purpose"),
(self.assignee_id, "Campaign hand-off assignee"),
):
if not value.strip():
raise ValueError(f"{label} is required")
references_existing = bool(self.campaign_id and self.campaign_id.strip())
creates_new = bool(
self.create_external_id
and self.create_external_id.strip()
and self.create_name
and self.create_name.strip()
)
if references_existing == creates_new:
raise ValueError(
"Campaign hand-offs must either reference one campaign or "
"declare one new campaign."
)
if self.expected_campaign_revision is not None and (
self.expected_campaign_revision < 1
):
raise ValueError("Expected Campaign revisions start at one")
if self.due_at is not None and self.due_at.tzinfo is None:
raise ValueError("Campaign hand-off due dates require a timezone")
@dataclass(frozen=True, slots=True)
class CampaignWorkHandoffRef:
"""Stable, revision-bearing reference returned to the Workflow instance."""
tenant_id: str
campaign_id: str
campaign_version_id: str
campaign_revision: int
assignment_id: str
assignment_revision: int
status: CampaignWorkHandoffStatus
action_url: str
campaign_ref: str
assignment_ref: str
event_type: str = "campaign.work.changed"
replayed: bool = False
optional_capabilities: Mapping[str, bool] = field(default_factory=dict)
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class CampaignWorkHandoffInspection:
"""Current authorization and revision check before Workflow continuation."""
allowed: bool
status: CampaignWorkHandoffStatus | None = None
assignment_revision: int | None = None
action_url: str | None = None
assignment_ref: str | None = None
reason: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class CampaignMailPolicyContextProvider(Protocol):
def get_campaign_mail_policy_context(
@@ -95,6 +192,9 @@ class CampaignPolicyContextProvider(Protocol):
@runtime_checkable
class CampaignDeliveryTaskProvider(Protocol):
def tenant_id_for_job(self, session: object, *, job_id: str) -> str | None:
...
def send_campaign_job(self, session: object, *, job_id: str, enqueue_imap_task: bool = True) -> Mapping[str, object]:
...
@@ -102,6 +202,21 @@ class CampaignDeliveryTaskProvider(Protocol):
...
@runtime_checkable
class CampaignScheduleProvider(Protocol):
"""Durable boundary for due manual drafts and governed autonomous occurrences."""
def dispatch_due(
self,
session: object,
*,
tenant_id: str | None = None,
now: datetime | None = None,
limit: int = 50,
) -> Mapping[str, object]:
...
@runtime_checkable
class CampaignRetentionProvider(Protocol):
def apply_retention(
@@ -113,3 +228,45 @@ class CampaignRetentionProvider(Protocol):
policy_for_campaign_id: Callable[[str | None], object],
) -> Mapping[str, Mapping[str, int]]:
...
@runtime_checkable
class CampaignWorkOrchestrationProvider(Protocol):
"""Optional Campaign boundary for durable Workflow-owned hand-offs."""
def prepare_handoff(
self,
session: object,
principal: object,
*,
request: CampaignWorkHandoffRequest,
) -> CampaignWorkHandoffRef:
...
def inspect_handoff(
self,
session: object,
principal: object,
*,
tenant_id: str,
assignment_id: str,
expected_revision: int | None = None,
) -> CampaignWorkHandoffInspection:
...
def campaign_work_orchestration_provider(
registry: object | None,
) -> CampaignWorkOrchestrationProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION)
):
return None
capability = registry.capability(CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION)
return (
capability
if isinstance(capability, CampaignWorkOrchestrationProvider)
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",
]
File diff suppressed because it is too large Load Diff
@@ -301,6 +301,18 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
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",
+226
View File
@@ -0,0 +1,226 @@
from __future__ import annotations
from collections import Counter
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
from urllib.parse import urlsplit
CONNECTOR_RUNTIME_CONTRACT_VERSION = "1.0"
ConnectorDiagnosticSeverity = Literal["info", "warning", "error"]
ConnectorDiagnosticStage = Literal[
"configuration",
"authentication",
"discovery",
"read",
"mapping",
"planning",
"apply",
"reconciliation",
]
ConnectorEffectKind = Literal[
"create",
"update",
"delete",
"conflict",
"unchanged",
"ignored",
]
ConnectorOutcomeState = Literal[
"preview",
"accepted",
"rejected",
"outcome_unknown",
]
class ConnectorContractError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class ConnectorEndpoint:
"""Sanitized endpoint identity. Credentials never belong in this value."""
url: str
credential_ref: str | None = None
tls_mode: Literal["required", "start_tls", "system", "disabled"] = "required"
options: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
normalized = self.url.strip()
parsed = urlsplit(normalized)
if not parsed.scheme or not parsed.hostname:
raise ConnectorContractError("Connector endpoints require an absolute URL.")
if parsed.username is not None or parsed.password is not None:
raise ConnectorContractError(
"Connector endpoint URLs must not contain credentials."
)
object.__setattr__(self, "url", normalized)
if self.credential_ref is not None:
credential_ref = self.credential_ref.strip()
if not credential_ref:
raise ConnectorContractError("Credential references cannot be blank.")
object.__setattr__(self, "credential_ref", credential_ref)
@dataclass(frozen=True, slots=True)
class ConnectorDiagnostic:
severity: ConnectorDiagnosticSeverity
code: str
message: str
stage: ConnectorDiagnosticStage
retryable: bool = False
source_ref: str | None = None
object_ref: str | None = None
details: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
if not self.code.strip() or len(self.code) > 120:
raise ConnectorContractError(
"Connector diagnostic codes must contain 1 to 120 characters."
)
if not self.message.strip():
raise ConnectorContractError("Connector diagnostic messages cannot be blank.")
@dataclass(frozen=True, slots=True)
class ConnectorEffectPreview:
effect: ConnectorEffectKind
source_object_ref: str
target_object_ref: str | None = None
changed_fields: tuple[str, ...] = ()
sample: Mapping[str, object] = field(default_factory=dict)
reason_code: str | None = None
outcome: ConnectorOutcomeState = "preview"
revision: str | None = None
def __post_init__(self) -> None:
if not self.source_object_ref.strip():
raise ConnectorContractError("Preview effects require a source object reference.")
if self.outcome != "preview":
raise ConnectorContractError("Dry-run effects must retain the preview outcome.")
@dataclass(frozen=True, slots=True)
class ConnectorEffectSummary:
creates: int = 0
updates: int = 0
deletes: int = 0
conflicts: int = 0
unchanged: int = 0
ignored: int = 0
@property
def total(self) -> int:
return (
self.creates
+ self.updates
+ self.deletes
+ self.conflicts
+ self.unchanged
+ self.ignored
)
@dataclass(frozen=True, slots=True)
class ConnectorDryRunRequest:
tenant_id: str
source_ref: str
force_full: bool = False
max_items: int = 1_000
expected_source_revision: str | None = None
context: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
if not self.tenant_id.strip() or not self.source_ref.strip():
raise ConnectorContractError("Dry runs require tenant and source references.")
if not 1 <= self.max_items <= 10_000:
raise ConnectorContractError("Dry-run max_items must be between 1 and 10000.")
@dataclass(frozen=True, slots=True)
class ConnectorDryRunResult:
contract_version: str
source_ref: str
source_revision: str
source_fingerprint: str
input_hash: str
generated_at: datetime
summary: ConnectorEffectSummary
effects: tuple[ConnectorEffectPreview, ...] = ()
diagnostics: tuple[ConnectorDiagnostic, ...] = ()
truncated: bool = False
stale: bool = False
apply_token: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
if self.contract_version != CONNECTOR_RUNTIME_CONTRACT_VERSION:
raise ConnectorContractError(
f"Unsupported connector contract version: {self.contract_version!r}."
)
for name in ("source_ref", "source_revision", "source_fingerprint", "input_hash"):
if not str(getattr(self, name)).strip():
raise ConnectorContractError(f"Dry-run {name} cannot be blank.")
if self.summary.total != len(self.effects):
raise ConnectorContractError(
"Dry-run summary counts must match the returned effect list."
)
@property
def can_apply(self) -> bool:
return (
self.apply_token is not None
and not self.truncated
and not self.stale
and self.summary.conflicts == 0
and not any(item.severity == "error" for item in self.diagnostics)
)
def summarize_connector_effects(
effects: tuple[ConnectorEffectPreview, ...],
) -> ConnectorEffectSummary:
counts = Counter(item.effect for item in effects)
return ConnectorEffectSummary(
creates=counts["create"],
updates=counts["update"],
deletes=counts["delete"],
conflicts=counts["conflict"],
unchanged=counts["unchanged"],
ignored=counts["ignored"],
)
@runtime_checkable
class ConnectorDryRunProvider(Protocol):
def preview(
self,
session: object,
principal: object,
*,
request: ConnectorDryRunRequest,
) -> ConnectorDryRunResult:
...
__all__ = [
"CONNECTOR_RUNTIME_CONTRACT_VERSION",
"ConnectorContractError",
"ConnectorDiagnostic",
"ConnectorDiagnosticSeverity",
"ConnectorDiagnosticStage",
"ConnectorDryRunProvider",
"ConnectorDryRunRequest",
"ConnectorDryRunResult",
"ConnectorEffectKind",
"ConnectorEffectPreview",
"ConnectorEffectSummary",
"ConnectorEndpoint",
"ConnectorOutcomeState",
"summarize_connector_effects",
]
+191
View File
@@ -0,0 +1,191 @@
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.distribution_lists import (
DistributionChannel,
DistributionExplanation,
DistributionOutcome,
DistributionSourceReference,
)
CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION = "addresses.contact_point_resolution"
CONTACT_POINT_CONTRACT_VERSION = "1.0"
ContactPointFallbackRule = Literal["none", "primary", "any"]
PostalAddressFormat = Literal["domestic", "international"]
@dataclass(frozen=True, slots=True)
class ContactPointResolutionRequest:
tenant_id: str
subject: DistributionSourceReference
effective_at: datetime
purpose: str | None = None
requested_channels: tuple[DistributionChannel, ...] = ()
address_purpose: str | None = None
fallback_rule: ContactPointFallbackRule = "primary"
locale: str | None = None
postal_format: PostalAddressFormat = "domestic"
context: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ContactPointCandidate:
channel: DistributionChannel
target: str
target_key: str
status: DistributionOutcome
contact_point_id: str | None = None
address_purpose: str | None = None
locale: str | None = None
preferred: bool = False
preference_rank: int | None = None
reason_code: str | None = None
explanation: str | None = None
source: DistributionSourceReference | None = None
source_revision: str | None = None
preference_revision: str | None = None
consent_revision: str | None = None
value: Mapping[str, object] = field(default_factory=dict)
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ContactPointResolution:
contract_version: str
subject: DistributionSourceReference
status: DistributionOutcome
contact_id: str | None = None
display_name: str | None = None
candidates: tuple[ContactPointCandidate, ...] = ()
excluded: tuple[ContactPointCandidate, ...] = ()
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 ContactPointSourceRequest:
tenant_id: str
source_id: str
effective_at: datetime
purpose: str | None = None
requested_channels: tuple[DistributionChannel, ...] = ()
address_purpose: str | None = None
fallback_rule: ContactPointFallbackRule = "primary"
locale: str | None = None
postal_format: PostalAddressFormat = "domestic"
max_items: int = 5_000
context: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ContactPointSourcePreview:
contract_version: str
source: DistributionSourceReference
request: ContactPointSourceRequest
resolutions: tuple[ContactPointResolution, ...]
total_count: int
usable_count: int
excluded_count: int
offset: int
limit: int
has_more: bool
source_revision: str
source_fingerprint: str
generated_at: datetime
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ContactPointSnapshotRef:
id: str
tenant_id: str
contract_version: str
source: DistributionSourceReference
request: ContactPointSourceRequest
resolutions: tuple[ContactPointResolution, ...]
recipient_count: int
excluded_count: int
source_revision: str
source_fingerprint: str
snapshot_hash: str
generated_at: datetime
provenance: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class ContactPointResolutionProvider(Protocol):
def resolve_contact_points(
self,
session: object,
principal: object,
*,
request: ContactPointResolutionRequest,
) -> ContactPointResolution:
...
def preview_source(
self,
session: object,
principal: object,
*,
request: ContactPointSourceRequest,
offset: int = 0,
limit: int = 100,
) -> ContactPointSourcePreview:
...
def freeze_source(
self,
session: object,
principal: object,
*,
request: ContactPointSourceRequest,
) -> ContactPointSnapshotRef:
...
def get_snapshot(
self,
session: object,
principal: object,
*,
snapshot_id: str,
) -> ContactPointSnapshotRef | None:
...
def contact_point_resolution_provider(
registry: object | None,
) -> ContactPointResolutionProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION)
):
return None
capability = registry.capability(CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION)
return capability if isinstance(capability, ContactPointResolutionProvider) else None
__all__ = [
"CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION",
"CONTACT_POINT_CONTRACT_VERSION",
"ContactPointCandidate",
"ContactPointFallbackRule",
"ContactPointResolution",
"ContactPointResolutionProvider",
"ContactPointResolutionRequest",
"ContactPointSnapshotRef",
"ContactPointSourcePreview",
"ContactPointSourceRequest",
"PostalAddressFormat",
"contact_point_resolution_provider",
]
+293
View File
@@ -0,0 +1,293 @@
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], ...] = ()
run_ref: str | None = None
@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,
*,
tenant_id: str | None = None,
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,
*,
tenant_id: str | None = None,
now: datetime | None = None,
limit: int = 10,
worker_id: str | None = None,
) -> Mapping[str, object]:
...
def purge_expired(
self,
session: object,
*,
tenant_id: str | None = None,
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",
]
+802
View File
@@ -0,0 +1,802 @@
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.access import PrincipalRef
from govoplan_core.core.external_references import (
SOURCE_AUTHORITY_MODES,
SourceAuthorityMode,
)
from govoplan_core.core.tabular_sources import (
DEFAULT_PREVIEW_BYTES,
DEFAULT_PREVIEW_TIMEOUT_MS,
TabularPreviewDiagnostic,
TabularPushdown,
TabularSourceHealth,
TabularSourceMode,
)
CAPABILITY_DATASOURCE_CATALOGUE = "datasources.catalogue"
CAPABILITY_DATASOURCE_LIFECYCLE = "datasources.lifecycle"
CAPABILITY_DATASOURCE_PUBLICATION = "datasources.publication"
CAPABILITY_DATASOURCE_ORIGINS = "connectors.datasourceOrigins"
CAPABILITY_DATASOURCE_ARTIFACT_BACKENDS = "datasources.artifactBackends"
CAPABILITY_POLICY_DATASOURCE_VISIBILITY = "policy.datasourceVisibility"
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"]
DatasourceVisibilityAction = Literal["discover", "read"]
DatasourcePublicationStatus = Literal[
"published",
"published_with_warnings",
"review_required",
]
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
classification: str = "internal"
@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
access_policy_ref: str | None = None
visibility_policy: Mapping[str, object] = field(default_factory=dict)
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)
approval_policy: Mapping[str, object] = field(default_factory=dict)
retention_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")
),
access_policy_ref=_optional_governance_text(
source.get("access_policy_ref")
),
visibility_policy=_governance_mapping(
source.get("visibility_policy")
),
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")),
approval_policy=_governance_mapping(source.get("approval_policy")),
retention_policy=_governance_mapping(source.get("retention_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,
"access_policy_ref": self.access_policy_ref,
"visibility_policy": dict(self.visibility_policy),
"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),
"approval_policy": dict(self.approval_policy),
"retention_policy": dict(self.retention_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 DatasourceVisibilityPolicyRequest:
tenant_id: str
datasource_ref: str
principal: PrincipalRef
action: DatasourceVisibilityAction
classification: str = "internal"
policy_ref: str | None = None
consistency: DatasourceConsistency = "current"
materialization_ref: str | None = None
@dataclass(frozen=True, slots=True)
class DatasourceVisibilityPolicyDecision:
allowed: bool
reason: str | None = None
policies: tuple[Mapping[str, object], ...] = ()
decision_ref: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class DatasourceVisibilityPolicyProvider(Protocol):
"""Optionally tighten Datasources-owned local visibility policy."""
def decide_datasource_visibility(
self,
session: object,
*,
request: DatasourceVisibilityPolicyRequest,
) -> DatasourceVisibilityPolicyDecision: ...
@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
disposed_at: datetime | None = None
disposition: Mapping[str, object] = field(default_factory=dict)
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)
approval: 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
max_bytes: int = DEFAULT_PREVIEW_BYTES
timeout_ms: int = DEFAULT_PREVIEW_TIMEOUT_MS
@dataclass(frozen=True, slots=True)
class DatasourceReadResult:
datasource: DatasourceDescriptor
rows: tuple[Mapping[str, object], ...]
total_rows: int
truncated: bool
materialization: DatasourceMaterialization | None = None
returned_bytes: int = 0
elapsed_ms: int = 0
effective_row_limit: int = 0
effective_byte_limit: int = 0
effective_timeout_ms: int = 0
diagnostics: tuple[TabularPreviewDiagnostic, ...] = ()
@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 DatasourceArtifactReference:
"""Immutable provider-neutral reference to a durable tabular payload.
The producer owns creation of the payload. Datasources pins its locator,
checksum and declared shape without importing the artifact-owning module;
a configured payload backend verifies integrity and provides bounded reads.
"""
backend: str
locator: str
checksum: str
row_count: int
byte_count: int
schema: tuple[DatasourceField, ...]
fingerprint: str
media_type: str = "application/x-ndjson"
checkpoint: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
validation: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class DatasourceArtifactBackend(Protocol):
"""Storage-module boundary for immutable artifact-backed tabular data."""
backend: str
def verify(
self,
session: object,
*,
tenant_id: str,
artifact: DatasourceArtifactReference,
) -> None: ...
def read_rows(
self,
session: object,
*,
tenant_id: str,
artifact: DatasourceArtifactReference,
offset: int,
limit: int,
) -> Sequence[Mapping[str, object]]: ...
def delete(
self,
session: object,
*,
tenant_id: str,
artifact: DatasourceArtifactReference,
) -> None: ...
@runtime_checkable
class DatasourceArtifactBackendProvider(Protocol):
def artifact_backends(self) -> Sequence[DatasourceArtifactBackend]: ...
@dataclass(frozen=True, slots=True)
class DatasourcePublicationRequest:
producer_module: str
producer_run_ref: str
idempotency_key: str
rows: tuple[Mapping[str, object], ...] | None = None
artifact: DatasourceArtifactReference | None = None
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: DatasourcePublicationStatus
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)
source_mode: TabularSourceMode = "cached"
pushdown: TabularPushdown = field(default_factory=TabularPushdown)
health: TabularSourceHealth = field(default_factory=TabularSourceHealth)
@dataclass(frozen=True, slots=True)
class DatasourceOriginReadRequest:
origin_ref: str
limit: int = 250
offset: int = 0
columns: tuple[str, ...] = ()
expected_fingerprint: str | None = None
max_bytes: int = DEFAULT_PREVIEW_BYTES
timeout_ms: int = DEFAULT_PREVIEW_TIMEOUT_MS
@dataclass(frozen=True, slots=True)
class DatasourceOriginReadResult:
origin: DatasourceOrigin
rows: tuple[Mapping[str, object], ...]
total_rows: int
truncated: bool
returned_bytes: int = 0
elapsed_ms: int = 0
effective_row_limit: int = 0
effective_byte_limit: int = 0
effective_timeout_ms: int = 0
diagnostics: tuple[TabularPreviewDiagnostic, ...] = ()
@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_artifact_backend_provider(
registry: object | None,
) -> DatasourceArtifactBackendProvider | None:
capability = _capability(registry, CAPABILITY_DATASOURCE_ARTIFACT_BACKENDS)
return (
capability
if isinstance(capability, DatasourceArtifactBackendProvider)
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 datasource_visibility_policy_provider(
registry: object | None,
) -> DatasourceVisibilityPolicyProvider | None:
capability = _capability(registry, CAPABILITY_POLICY_DATASOURCE_VISIBILITY)
return capability if isinstance(capability, DatasourceVisibilityPolicyProvider) 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_ARTIFACT_BACKENDS",
"CAPABILITY_DATASOURCE_LIFECYCLE",
"CAPABILITY_DATASOURCE_ORIGINS",
"CAPABILITY_DATASOURCE_PUBLICATION",
"CAPABILITY_POLICY_DATASOURCE_VISIBILITY",
"DatasourceAccessError",
"DatasourceArtifactReference",
"DatasourceArtifactBackend",
"DatasourceArtifactBackendProvider",
"DatasourceCatalogueProvider",
"DatasourceConsistency",
"DatasourceDescriptor",
"DatasourceError",
"DatasourceField",
"DatasourceGovernance",
"DatasourceKind",
"DatasourceLifecycleProvider",
"DatasourceMaterialization",
"DatasourceMode",
"DatasourceNotFoundError",
"DatasourceOrigin",
"DatasourceOriginProvider",
"DatasourceOriginReadRequest",
"DatasourceOriginReadResult",
"DatasourcePublicationProvider",
"DatasourcePublicationRequest",
"DatasourcePublicationResult",
"DatasourcePublicationStatus",
"DatasourceReadRequest",
"DatasourceReadResult",
"DatasourceShape",
"DatasourceStage",
"DatasourceStageInput",
"DatasourceUnavailableError",
"DatasourceValidationError",
"DatasourceVisibilityAction",
"DatasourceVisibilityPolicyDecision",
"DatasourceVisibilityPolicyProvider",
"DatasourceVisibilityPolicyRequest",
"datasource_catalogue",
"datasource_artifact_backend_provider",
"datasource_lifecycle",
"datasource_origins",
"datasource_publication",
"datasource_visibility_policy_provider",
]
+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_")]
+197
View File
@@ -0,0 +1,197 @@
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
DSAR_CAPABILITY_PREFIX = "privacy.dsar."
DsarRequestKind = Literal["access", "erasure", "access_and_erasure"]
DsarActionKind = Literal[
"delete",
"anonymize",
"revoke",
"detach",
"retain",
"manual_review",
]
@dataclass(frozen=True, slots=True)
class DsarSubjectRef:
account_id: str | None = None
identity_id: str | None = None
membership_id: str | None = None
email: str | None = None
external_references: Mapping[str, str] = field(default_factory=dict)
def has_selector(self) -> bool:
return bool(
self.account_id
or self.identity_id
or self.membership_id
or self.email
or self.external_references
)
def to_dict(self) -> dict[str, object]:
return {
"account_id": self.account_id,
"identity_id": self.identity_id,
"membership_id": self.membership_id,
"email": self.email,
"external_references": dict(self.external_references),
}
@dataclass(frozen=True, slots=True)
class DsarRecordRef:
provider_id: str
module_id: str
resource_type: str
resource_id: str
category: str
title: str
data: Mapping[str, object] = field(default_factory=dict)
observed_at: datetime | None = None
immutable_evidence: bool = False
retention_reason: str | None = None
source_path: str | None = None
def to_dict(self) -> dict[str, object]:
return {
"provider_id": self.provider_id,
"module_id": self.module_id,
"resource_type": self.resource_type,
"resource_id": self.resource_id,
"category": self.category,
"title": self.title,
"data": dict(self.data),
"observed_at": self.observed_at.isoformat() if self.observed_at else None,
"immutable_evidence": self.immutable_evidence,
"retention_reason": self.retention_reason,
"source_path": self.source_path,
}
@dataclass(frozen=True, slots=True)
class DsarErasureActionRef:
action_id: str
provider_id: str
module_id: str
kind: DsarActionKind
resource_type: str
resource_id: str
title: str
rationale: str
executable: bool
irreversible: bool = False
metadata: Mapping[str, object] = field(default_factory=dict)
def to_dict(self) -> dict[str, object]:
return {
"action_id": self.action_id,
"provider_id": self.provider_id,
"module_id": self.module_id,
"kind": self.kind,
"resource_type": self.resource_type,
"resource_id": self.resource_id,
"title": self.title,
"rationale": self.rationale,
"executable": self.executable,
"irreversible": self.irreversible,
"metadata": dict(self.metadata),
}
@dataclass(frozen=True, slots=True)
class DsarExecutionResultRef:
action_id: str
status: Literal["executed", "unchanged", "failed", "blocked"]
summary: str
evidence: Mapping[str, object] = field(default_factory=dict)
def to_dict(self) -> dict[str, object]:
return {
"action_id": self.action_id,
"status": self.status,
"summary": self.summary,
"evidence": dict(self.evidence),
}
@runtime_checkable
class DsarProvider(Protocol):
provider_id: str
module_id: str
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]: ...
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]: ...
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]: ...
def dsar_capability_name(module_id: str) -> str:
normalized = module_id.strip().casefold()
if not normalized or not normalized.replace("_", "").isalnum():
raise ValueError("DSAR module id must be an identifier.")
return f"{DSAR_CAPABILITY_PREFIX}{normalized}"
def dsar_provider_names(registry: object | None) -> tuple[str, ...]:
if registry is None or not hasattr(registry, "capability_names"):
return ()
return tuple(
name
for name in registry.capability_names()
if name.startswith(DSAR_CAPABILITY_PREFIX)
)
def dsar_provider(
registry: object,
capability_name: str,
) -> DsarProvider:
provider = registry.require_capability(capability_name)
if not isinstance(provider, DsarProvider):
raise TypeError(f"{capability_name} does not implement DsarProvider")
return provider
__all__ = [
"DSAR_CAPABILITY_PREFIX",
"DsarActionKind",
"DsarErasureActionRef",
"DsarExecutionResultRef",
"DsarProvider",
"DsarRecordRef",
"DsarRequestKind",
"DsarSubjectRef",
"dsar_capability_name",
"dsar_provider",
"dsar_provider_names",
]
File diff suppressed because it is too large Load Diff
+200 -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,118 @@ 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,
*,
tenant_id: str | None = None,
tenantless_only: bool = False,
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,
*,
tenant_id: str | None = None,
tenantless_only: bool = False,
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 +266,7 @@ def ensure_event_trace(event: PlatformEvent) -> PlatformEvent:
subject=event.subject,
resource=event.resource,
classification=event.classification,
institutional_context=event.institutional_context,
)
@@ -189,5 +307,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
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",
]
+215
View File
@@ -1,13 +1,228 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
from govoplan_core.core.access import ResourceAccessExplanationProvider
CAPABILITY_FILES_ACCESS = "files.access"
CAPABILITY_FILES_ARTIFACT_STORE = "files.artifact_store"
CAPABILITY_FILES_POSTBOX_REFERENCES = "files.postbox_references"
CAPABILITY_FILES_TABULAR_CONTENT = "files.tabular_content"
class ManagedTabularFileError(ValueError):
"""Stable base error for exact-version managed tabular file access."""
class ManagedTabularFileNotFoundError(ManagedTabularFileError):
pass
class ManagedTabularFileAccessError(ManagedTabularFileError):
pass
class ManagedTabularFileUnavailableError(ManagedTabularFileError):
pass
class ManagedTabularFileValidationError(ManagedTabularFileError):
pass
@dataclass(frozen=True, slots=True)
class ManagedTabularFile:
"""Authorized metadata for one immutable managed file version."""
file_asset_id: str
file_version_id: str
filename: str
display_path: str
content_type: str | None
size_bytes: int
sha256: str
updated_at: datetime | None = None
current_version: bool = True
@dataclass(frozen=True, slots=True)
class ManagedTabularFileContent:
file: ManagedTabularFile
payload: bytes
@dataclass(frozen=True, slots=True)
class ManagedArtifactWriteRequest:
filename: str
payload: bytes
content_type: str
folder: str = "Generated"
description: str | None = None
idempotency_key: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ManagedArtifactRef:
file_asset_id: str
file_version_id: str
filename: str
display_path: str
content_type: str
size_bytes: int
sha256: str
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxFileReferenceRequest:
reference_type: str
reference_id: str
postbox_id: str
message_id: str
@dataclass(frozen=True, slots=True)
class PostboxFileReferenceRef:
reference_type: str
reference_id: str
available: bool
reason_code: str
file_asset_id: str | None = None
file_version_id: str | None = None
filename: str | None = None
content_type: str | None = None
size_bytes: int | None = None
sha256: str | None = None
download_path: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class FileAccessProvider(ResourceAccessExplanationProvider, Protocol):
"""Resource-level access explanation provider for Files-owned resources."""
@runtime_checkable
class ManagedArtifactStore(Protocol):
"""Store generated module artifacts without exposing Files internals."""
def store_artifact(
self,
session: object,
principal: object,
*,
request: ManagedArtifactWriteRequest,
) -> ManagedArtifactRef: ...
@runtime_checkable
class PostboxFileReferenceProvider(Protocol):
"""Resolve Files-owned references after Postbox and Files authorization."""
def resolve_postbox_references(
self,
session: object,
principal: object,
*,
tenant_id: str,
requests: tuple[PostboxFileReferenceRequest, ...],
) -> tuple[PostboxFileReferenceRef, ...]: ...
@runtime_checkable
class ManagedTabularFileProvider(Protocol):
"""List and open authorized CSV/XLSX content without exposing Files internals."""
def list_tabular_files(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
) -> tuple[ManagedTabularFile, ...]: ...
def get_tabular_file(
self,
session: object,
principal: object,
*,
file_asset_id: str,
file_version_id: str | None = None,
) -> ManagedTabularFile | None: ...
def read_tabular_file(
self,
session: object,
principal: object,
*,
file_asset_id: str,
file_version_id: str,
max_bytes: int,
) -> ManagedTabularFileContent: ...
def postbox_file_reference_provider(
registry: object | None,
) -> PostboxFileReferenceProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_FILES_POSTBOX_REFERENCES)
):
return None
provider = registry.require_capability(CAPABILITY_FILES_POSTBOX_REFERENCES)
if not isinstance(provider, PostboxFileReferenceProvider):
raise TypeError(
"files.postbox_references provider does not implement "
"PostboxFileReferenceProvider"
)
return provider
def managed_tabular_file_provider(
registry: object | None,
) -> ManagedTabularFileProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_FILES_TABULAR_CONTENT)
):
return None
provider = registry.require_capability(CAPABILITY_FILES_TABULAR_CONTENT)
if not isinstance(provider, ManagedTabularFileProvider):
raise TypeError(
"files.tabular_content provider does not implement "
"ManagedTabularFileProvider"
)
return provider
__all__ = [
"CAPABILITY_FILES_ACCESS",
"CAPABILITY_FILES_ARTIFACT_STORE",
"CAPABILITY_FILES_POSTBOX_REFERENCES",
"CAPABILITY_FILES_TABULAR_CONTENT",
"FileAccessProvider",
"ManagedArtifactRef",
"ManagedArtifactStore",
"ManagedArtifactWriteRequest",
"ManagedTabularFile",
"ManagedTabularFileAccessError",
"ManagedTabularFileContent",
"ManagedTabularFileError",
"ManagedTabularFileNotFoundError",
"ManagedTabularFileProvider",
"ManagedTabularFileUnavailableError",
"ManagedTabularFileValidationError",
"PostboxFileReferenceProvider",
"PostboxFileReferenceRef",
"PostboxFileReferenceRequest",
"managed_tabular_file_provider",
"postbox_file_reference_provider",
]
+532
View File
@@ -0,0 +1,532 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import StrEnum
import hashlib
import hmac
import json
import re
import secrets
from typing import Any
from uuid import uuid4
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, UniqueConstraint, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Mapped, Session, mapped_column
from govoplan_core.audit.logging import audit_event
from govoplan_core.core.access import (
FirstAdminProvisioner,
FirstAdminProvisioningError,
FirstSystemAdministratorRef,
)
from govoplan_core.db.base import Base, TimestampMixin, utcnow
from govoplan_core.tenancy.scope import Tenant
_TENANT_SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
class FirstAdminEnrollmentState(StrEnum):
INACTIVE = "inactive"
ACTIVE = "active"
CONSUMED = "consumed"
REVOKED = "revoked"
class FirstAdminEnrollmentError(RuntimeError):
pass
class FirstAdminEnrollmentUnavailable(FirstAdminEnrollmentError):
pass
class FirstAdminEnrollmentCredentialError(FirstAdminEnrollmentError):
pass
class FirstAdminEnrollmentConflict(FirstAdminEnrollmentError):
pass
class FirstAdminEnrollment(Base, TimestampMixin):
__tablename__ = "core_first_admin_enrollments"
installation_id: Mapped[str] = mapped_column(String(100), primary_key=True)
state: Mapped[str] = mapped_column(
String(24),
default=FirstAdminEnrollmentState.INACTIVE.value,
nullable=False,
index=True,
)
generation: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
token_sha256: Mapped[str | None] = mapped_column(String(64))
token_fingerprint: Mapped[str | None] = mapped_column(String(16))
issued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
consumed_account_id: Mapped[str | None] = mapped_column(String(36))
consumed_membership_id: Mapped[str | None] = mapped_column(String(36))
consumed_tenant_id: Mapped[str | None] = mapped_column(String(36))
consumed_email: Mapped[str | None] = mapped_column(String(320))
consumed_display_name: Mapped[str | None] = mapped_column(String(255))
consumed_request_sha256: Mapped[str | None] = mapped_column(String(64))
issue_reason: Mapped[str | None] = mapped_column(String(500))
event_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
evidence_head_sha256: Mapped[str | None] = mapped_column(String(64))
class FirstAdminEnrollmentEvent(Base):
__tablename__ = "core_first_admin_enrollment_events"
__table_args__ = (
UniqueConstraint(
"installation_id",
"sequence",
name="uq_core_first_admin_enrollment_event_sequence",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid4()),
)
installation_id: Mapped[str] = mapped_column(
ForeignKey(
"core_first_admin_enrollments.installation_id",
ondelete="CASCADE",
),
nullable=False,
index=True,
)
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
event_type: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
generation: Mapped[int] = mapped_column(Integer, nullable=False)
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
previous_sha256: Mapped[str | None] = mapped_column(String(64))
event_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 IssuedFirstAdminCredential:
secret: str
fingerprint: str
generation: int
expires_at: datetime
@dataclass(frozen=True, slots=True)
class FirstAdminEnrollmentStatus:
enrollment_required: bool
credential_active: bool
state: str
generation: int
expires_at: datetime | None
completed_account_id: str | None
readiness: dict[str, bool]
@dataclass(frozen=True, slots=True)
class FirstAdminEnrollmentResult:
administrator: FirstSystemAdministratorRef
replayed: bool
def issue_first_admin_credential(
session: Session,
*,
installation_id: str,
provisioner: FirstAdminProvisioner,
ttl_seconds: int,
reason: str,
replace_active: bool = False,
now: datetime | None = None,
) -> IssuedFirstAdminCredential:
current_time = _utc(now)
if ttl_seconds < 60 or ttl_seconds > 24 * 60 * 60:
raise ValueError("First-admin enrollment expiry must be between 60 seconds and 24 hours.")
if provisioner.has_durable_system_administrator(session):
raise FirstAdminEnrollmentUnavailable(
"A durable system administrator already exists. Bootstrap enrollment is disabled."
)
enrollment = _locked_enrollment(session, installation_id)
if (
enrollment.state == FirstAdminEnrollmentState.ACTIVE.value
and _is_future(enrollment.expires_at, current_time)
and not replace_active
):
raise FirstAdminEnrollmentConflict(
"An unexpired first-admin credential already exists. Use the recovery command to rotate it."
)
secret = secrets.token_urlsafe(48)
token_sha256 = _secret_sha256(secret)
fingerprint = token_sha256[:12]
expires_at = current_time + timedelta(seconds=ttl_seconds)
generation = enrollment.generation + 1
if enrollment.state == FirstAdminEnrollmentState.ACTIVE.value:
_append_event(
session,
enrollment,
event_type="credential_revoked",
generation=enrollment.generation,
created_at=current_time,
evidence={"reason": "local_operator_recovery"},
)
enrollment.state = FirstAdminEnrollmentState.ACTIVE.value
enrollment.generation = generation
enrollment.token_sha256 = token_sha256
enrollment.token_fingerprint = fingerprint
enrollment.issued_at = current_time
enrollment.expires_at = expires_at
enrollment.consumed_at = None
enrollment.consumed_account_id = None
enrollment.consumed_membership_id = None
enrollment.consumed_tenant_id = None
enrollment.consumed_email = None
enrollment.consumed_display_name = None
enrollment.consumed_request_sha256 = None
enrollment.issue_reason = _bounded_reason(reason)
session.add(enrollment)
_append_event(
session,
enrollment,
event_type="credential_issued",
generation=generation,
created_at=current_time,
evidence={
"fingerprint": fingerprint,
"expires_at": expires_at.isoformat(),
"reason": enrollment.issue_reason,
},
)
audit_event(
session,
tenant_id=None,
scope="system",
action="access.first_admin_enrollment.issued",
object_type="first_admin_enrollment",
object_id=installation_id,
details={
"generation": generation,
"fingerprint": fingerprint,
"expires_at": expires_at.isoformat(),
"reason": enrollment.issue_reason,
},
)
return IssuedFirstAdminCredential(
secret=secret,
fingerprint=fingerprint,
generation=generation,
expires_at=expires_at,
)
def first_admin_enrollment_status(
session: Session,
*,
installation_id: str,
provisioner: FirstAdminProvisioner,
now: datetime | None = None,
) -> FirstAdminEnrollmentStatus:
current_time = _utc(now)
administrator_exists = provisioner.has_durable_system_administrator(session)
enrollment = session.get(FirstAdminEnrollment, installation_id)
state = enrollment.state if enrollment is not None else FirstAdminEnrollmentState.INACTIVE.value
active = bool(
not administrator_exists
and enrollment is not None
and state == FirstAdminEnrollmentState.ACTIVE.value
and enrollment.token_sha256
and _is_future(enrollment.expires_at, current_time)
)
if (
not administrator_exists
and enrollment is not None
and state == FirstAdminEnrollmentState.ACTIVE.value
and not active
):
state = "expired"
return FirstAdminEnrollmentStatus(
enrollment_required=not administrator_exists,
credential_active=active,
state="completed" if administrator_exists else state,
generation=enrollment.generation if enrollment is not None else 0,
expires_at=enrollment.expires_at if enrollment is not None else None,
completed_account_id=(
enrollment.consumed_account_id if enrollment is not None else None
),
readiness={
"database": True,
"access_capability": True,
"administrator_absent": not administrator_exists,
},
)
def consume_first_admin_credential(
session: Session,
*,
installation_id: str,
provisioner: FirstAdminProvisioner,
secret: str,
email: str,
display_name: str | None,
password: str,
tenant_slug: str,
tenant_name: str,
now: datetime | None = None,
) -> FirstAdminEnrollmentResult:
current_time = _utc(now)
normalized_email = email.strip().casefold()
clean_display_name = display_name.strip() if display_name and display_name.strip() else None
clean_tenant_slug = tenant_slug.strip().casefold()
clean_tenant_name = tenant_name.strip()
if not normalized_email or "@" not in normalized_email:
raise FirstAdminEnrollmentConflict("Enter a valid administrator email address.")
if len(password) < 12:
raise FirstAdminEnrollmentConflict("The administrator password must contain at least 12 characters.")
if not _TENANT_SLUG_RE.fullmatch(clean_tenant_slug):
raise FirstAdminEnrollmentConflict(
"The initial tenant slug may contain lowercase letters, numbers, and single hyphens."
)
if not clean_tenant_name:
raise FirstAdminEnrollmentConflict("Enter a name for the initial tenant.")
request_sha256 = _request_sha256(
email=normalized_email,
display_name=clean_display_name,
tenant_slug=clean_tenant_slug,
tenant_name=clean_tenant_name,
)
supplied_sha256 = _secret_sha256(secret)
enrollment = session.execute(
select(FirstAdminEnrollment)
.where(FirstAdminEnrollment.installation_id == installation_id)
.with_for_update()
).scalar_one_or_none()
if enrollment is None:
raise FirstAdminEnrollmentCredentialError("First-admin enrollment is not active.")
if enrollment.state == FirstAdminEnrollmentState.CONSUMED.value:
if (
enrollment.token_sha256
and hmac.compare_digest(enrollment.token_sha256, supplied_sha256)
and enrollment.consumed_request_sha256 == request_sha256
and enrollment.consumed_account_id
and enrollment.consumed_email
):
return FirstAdminEnrollmentResult(
administrator=FirstSystemAdministratorRef(
account_id=enrollment.consumed_account_id,
email=enrollment.consumed_email,
display_name=enrollment.consumed_display_name,
membership_id=enrollment.consumed_membership_id,
tenant_id=enrollment.consumed_tenant_id,
),
replayed=True,
)
raise FirstAdminEnrollmentCredentialError("The first-admin credential has already been used.")
if enrollment.state != FirstAdminEnrollmentState.ACTIVE.value or not enrollment.token_sha256:
raise FirstAdminEnrollmentCredentialError("First-admin enrollment is not active.")
if not _is_future(enrollment.expires_at, current_time):
raise FirstAdminEnrollmentCredentialError(
"The first-admin credential has expired. A local operator must issue a replacement."
)
if not hmac.compare_digest(enrollment.token_sha256, supplied_sha256):
raise FirstAdminEnrollmentCredentialError("The first-admin credential is invalid.")
if provisioner.has_durable_system_administrator(session):
raise FirstAdminEnrollmentUnavailable(
"A durable system administrator already exists. Bootstrap enrollment is disabled."
)
tenant = session.execute(
select(Tenant).where(Tenant.slug == clean_tenant_slug).with_for_update()
).scalar_one_or_none()
if tenant is None:
tenant = Tenant(
slug=clean_tenant_slug,
name=clean_tenant_name,
default_locale="de",
settings={},
is_active=True,
)
session.add(tenant)
session.flush()
elif not tenant.is_active:
raise FirstAdminEnrollmentConflict("The selected initial tenant is inactive.")
try:
administrator = provisioner.create_first_system_administrator(
session,
tenant=tenant,
email=normalized_email,
display_name=clean_display_name,
password=password,
)
except FirstAdminProvisioningError as exc:
raise FirstAdminEnrollmentConflict(str(exc)) from exc
enrollment.state = FirstAdminEnrollmentState.CONSUMED.value
enrollment.consumed_at = current_time
enrollment.consumed_account_id = administrator.account_id
enrollment.consumed_membership_id = administrator.membership_id
enrollment.consumed_tenant_id = administrator.tenant_id
enrollment.consumed_email = administrator.email
enrollment.consumed_display_name = administrator.display_name
enrollment.consumed_request_sha256 = request_sha256
session.add(enrollment)
_append_event(
session,
enrollment,
event_type="administrator_created",
generation=enrollment.generation,
created_at=current_time,
evidence={
"account_id": administrator.account_id,
"membership_id": administrator.membership_id,
"tenant_id": administrator.tenant_id,
"email_sha256": hashlib.sha256(normalized_email.encode("utf-8")).hexdigest(),
},
)
audit_event(
session,
tenant_id=None,
scope="system",
action="access.first_admin_enrollment.completed",
object_type="access_account",
object_id=administrator.account_id,
details={
"generation": enrollment.generation,
"membership_id": administrator.membership_id,
"tenant_id": administrator.tenant_id,
"credential_invalidated": True,
},
)
return FirstAdminEnrollmentResult(administrator=administrator, replayed=False)
def _locked_enrollment(session: Session, installation_id: str) -> FirstAdminEnrollment:
enrollment = session.execute(
select(FirstAdminEnrollment)
.where(FirstAdminEnrollment.installation_id == installation_id)
.with_for_update()
).scalar_one_or_none()
if enrollment is not None:
return enrollment
enrollment = FirstAdminEnrollment(installation_id=installation_id)
try:
with session.begin_nested():
session.add(enrollment)
session.flush()
except IntegrityError:
enrollment = session.execute(
select(FirstAdminEnrollment)
.where(FirstAdminEnrollment.installation_id == installation_id)
.with_for_update()
).scalar_one()
return enrollment
def _append_event(
session: Session,
enrollment: FirstAdminEnrollment,
*,
event_type: str,
generation: int,
created_at: datetime,
evidence: dict[str, Any],
) -> None:
sequence = enrollment.event_count + 1
payload = {
"installation_id": enrollment.installation_id,
"sequence": sequence,
"event_type": event_type,
"generation": generation,
"created_at": created_at.isoformat(),
"evidence": evidence,
"previous_sha256": enrollment.evidence_head_sha256,
}
event_sha256 = hashlib.sha256(
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
session.add(
FirstAdminEnrollmentEvent(
installation_id=enrollment.installation_id,
sequence=sequence,
event_type=event_type,
generation=generation,
evidence=evidence,
previous_sha256=enrollment.evidence_head_sha256,
event_sha256=event_sha256,
created_at=created_at,
)
)
enrollment.event_count = sequence
enrollment.evidence_head_sha256 = event_sha256
session.add(enrollment)
def _request_sha256(
*,
email: str,
display_name: str | None,
tenant_slug: str,
tenant_name: str,
) -> str:
payload = {
"email": email,
"display_name": display_name,
"tenant_slug": tenant_slug,
"tenant_name": tenant_name,
}
return hashlib.sha256(
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
def _secret_sha256(secret: str) -> str:
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
def _utc(value: datetime | None) -> datetime:
candidate = value or datetime.now(timezone.utc)
if candidate.tzinfo is None:
return candidate.replace(tzinfo=timezone.utc)
return candidate.astimezone(timezone.utc)
def _is_future(value: datetime | None, now: datetime) -> bool:
return value is not None and _utc(value) > now
def _bounded_reason(value: str) -> str:
clean = value.strip()
if not clean:
raise ValueError("A local operator reason is required.")
return clean[:500]
__all__ = [
"FirstAdminEnrollment",
"FirstAdminEnrollmentConflict",
"FirstAdminEnrollmentCredentialError",
"FirstAdminEnrollmentError",
"FirstAdminEnrollmentEvent",
"FirstAdminEnrollmentResult",
"FirstAdminEnrollmentState",
"FirstAdminEnrollmentStatus",
"FirstAdminEnrollmentUnavailable",
"IssuedFirstAdminCredential",
"consume_first_admin_credential",
"first_admin_enrollment_status",
"issue_first_admin_credential",
]
+227
View File
@@ -0,0 +1,227 @@
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.institutional import EvidenceReference, InstitutionalReference
CAPABILITY_FORM_EVIDENCE_PREFIX = "forms_runtime.evidence."
FormEvidenceState = Literal[
"accepted",
"pending",
"rejected",
"expired",
"revoked",
"unavailable",
]
_FORM_EVIDENCE_STATES = {
"accepted",
"pending",
"rejected",
"expired",
"revoked",
"unavailable",
}
class FormEvidenceContractError(ValueError):
"""Stable error for provider-neutral Form evidence operations."""
@dataclass(frozen=True, slots=True)
class FormEvidenceGrantRequest:
"""Request a short-lived, purpose-bound grant from an evidence owner."""
tenant_id: str
instance_id: str
definition_ref: InstitutionalReference
evidence_kind: str
purpose: str
idempotency_key: str
expires_at: datetime
custodian_ref: str | None = None
max_size_bytes: int | None = None
allowed_content_types: tuple[str, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
_require_text(self.tenant_id, "Form evidence tenant")
_require_text(self.instance_id, "Form evidence instance")
_require_text(self.evidence_kind, "Form evidence kind")
_require_text(self.purpose, "Form evidence purpose")
_require_text(self.idempotency_key, "Form evidence idempotency key")
if (
self.definition_ref.kind != "form"
or self.definition_ref.tenant_id != self.tenant_id
or not self.definition_ref.version
):
raise FormEvidenceContractError(
"Form evidence grants require an exact same-tenant Form definition."
)
if self.expires_at.tzinfo is None or self.expires_at.utcoffset() is None:
raise FormEvidenceContractError(
"Form evidence grant expiry must include a timezone."
)
if self.max_size_bytes is not None and self.max_size_bytes <= 0:
raise FormEvidenceContractError(
"Form evidence grant size limits must be positive."
)
if any(not item.strip() for item in self.allowed_content_types):
raise FormEvidenceContractError(
"Form evidence content types cannot be empty."
)
@dataclass(frozen=True, slots=True)
class FormEvidenceGrant:
provider_id: str
grant_id: str
upload_token: str | None
upload_url: str
expires_at: datetime
max_size_bytes: int
allowed_content_types: tuple[str, ...] = ()
replayed: bool = False
def __post_init__(self) -> None:
for value, label in (
(self.provider_id, "Form evidence provider"),
(self.grant_id, "Form evidence grant"),
(self.upload_url, "Form evidence upload URL"),
):
_require_text(value, label)
if self.upload_token is not None:
_require_text(self.upload_token, "Form evidence upload token")
if self.max_size_bytes <= 0:
raise FormEvidenceContractError(
"Form evidence grant size limits must be positive."
)
if self.expires_at.tzinfo is None or self.expires_at.utcoffset() is None:
raise FormEvidenceContractError(
"Form evidence grant expiry must include a timezone."
)
@dataclass(frozen=True, slots=True)
class FormEvidenceInspectionRequest:
tenant_id: str
instance_id: str
definition_ref: InstitutionalReference
evidence: EvidenceReference
purpose: str
final: bool
def __post_init__(self) -> None:
_require_text(self.tenant_id, "Form evidence tenant")
_require_text(self.instance_id, "Form evidence instance")
_require_text(self.purpose, "Form evidence purpose")
if self.definition_ref.tenant_id != self.tenant_id:
raise FormEvidenceContractError(
"Form evidence inspection cannot cross tenants."
)
if self.evidence.tenant_id != self.tenant_id:
raise FormEvidenceContractError(
"Form evidence inspection cannot cross tenants."
)
@dataclass(frozen=True, slots=True)
class FormEvidenceInspection:
provider_id: str
reference: EvidenceReference
state: FormEvidenceState
observed_at: datetime
retryable: bool = False
reason: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
_require_text(self.provider_id, "Form evidence provider")
if self.state not in _FORM_EVIDENCE_STATES:
raise FormEvidenceContractError(
f"Unsupported Form evidence state: {self.state!r}."
)
if self.observed_at.tzinfo is None or self.observed_at.utcoffset() is None:
raise FormEvidenceContractError(
"Form evidence inspection time must include a timezone."
)
if self.state == "accepted" and self.retryable:
raise FormEvidenceContractError(
"Accepted Form evidence cannot require a retry."
)
@property
def accepted(self) -> bool:
return self.state == "accepted"
@runtime_checkable
class FormEvidenceProvider(Protocol):
provider_id: str
def supported_kinds(self) -> Sequence[str]: ...
def create_upload_grant(
self,
session: object,
principal: object,
*,
request: FormEvidenceGrantRequest,
) -> FormEvidenceGrant: ...
def inspect_evidence(
self,
session: object,
principal: object,
*,
request: FormEvidenceInspectionRequest,
) -> FormEvidenceInspection: ...
def form_evidence_capability(provider_id: str) -> str:
normalized = str(provider_id or "").strip().lower().replace("-", "_")
if not normalized or not normalized.replace("_", "").isalnum():
raise FormEvidenceContractError("Invalid Form evidence provider id.")
return f"{CAPABILITY_FORM_EVIDENCE_PREFIX}{normalized}"
def form_evidence_provider(
registry: object | None,
provider_id: str,
) -> FormEvidenceProvider | None:
capability_name = form_evidence_capability(provider_id)
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(capability_name):
return None
if hasattr(registry, "require_capability"):
provider = registry.require_capability(capability_name)
elif hasattr(registry, "capability"):
provider = registry.capability(capability_name)
else:
return None
return provider if isinstance(provider, FormEvidenceProvider) else None
def _require_text(value: str, label: str) -> None:
if not isinstance(value, str) or not value.strip():
raise FormEvidenceContractError(f"{label} is required.")
__all__ = [
"CAPABILITY_FORM_EVIDENCE_PREFIX",
"FormEvidenceContractError",
"FormEvidenceGrant",
"FormEvidenceGrantRequest",
"FormEvidenceInspection",
"FormEvidenceInspectionRequest",
"FormEvidenceProvider",
"FormEvidenceState",
"form_evidence_capability",
"form_evidence_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",
]
+240 -2
View File
@@ -1,16 +1,21 @@
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
IDM_MODULE_ID = "idm"
CAPABILITY_IDM_DIRECTORY = "idm.directory"
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS = "idm.function_assignments"
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE = "idm.assignment_lifecycle"
CAPABILITY_IDM_RELATIONSHIPS = "idm.relationships"
IdmStatus = Literal["active", "inactive", "suspended"]
OrganizationFunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
TypedGroupStatus = Literal["active", "inactive"]
IdentityRelationshipStatus = Literal["active", "revoked"]
@dataclass(frozen=True, slots=True)
@@ -30,6 +35,90 @@ 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
@dataclass(frozen=True, slots=True)
class TypedGroupRef:
"""Provider-neutral IDM group fact scoped to one tenant."""
id: str
tenant_id: str
key: str
name: str
group_type: str
description: str | None = None
status: TypedGroupStatus = "active"
source_provider: str = "local"
source_resource_type: str | None = None
source_resource_id: str | None = None
source_revision: str | None = None
properties: Mapping[str, object] = field(default_factory=dict)
provenance: Mapping[str, object] = field(default_factory=dict)
revision: int = 1
@dataclass(frozen=True, slots=True)
class IdentityRelationshipRef:
"""An effective-dated relationship from an identity to a typed target."""
id: str
tenant_id: str
relationship_kind: str
subject_identity_id: str
target_group_id: str | None = None
related_identity_id: str | None = None
role: str | None = None
valid_from: datetime | None = None
valid_until: datetime | None = None
status: IdentityRelationshipStatus = "active"
revoked_at: datetime | None = None
revoked_by: str | None = None
revocation_reason: str | None = None
source_provider: str = "local"
source_resource_type: str | None = None
source_resource_id: str | None = None
source_revision: str | None = None
properties: Mapping[str, object] = field(default_factory=dict)
provenance: Mapping[str, object] = field(default_factory=dict)
revision: int = 1
@dataclass(frozen=True, slots=True)
class IdentityRelationshipDecisionRef:
relationship: IdentityRelationshipRef
included: bool
code: str
explanation: str
identity_status: IdmStatus | None = None
@dataclass(frozen=True, slots=True)
class TypedGroupMembershipResolutionRef:
group: TypedGroupRef
effective_at: datetime
decisions: tuple[IdentityRelationshipDecisionRef, ...] = ()
@property
def identity_ids(self) -> tuple[str, ...]:
return tuple(
dict.fromkeys(
item.relationship.subject_identity_id
for item in self.decisions
if item.included
)
)
@runtime_checkable
class IdmDirectory(Protocol):
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
@@ -40,6 +129,7 @@ class IdmDirectory(Protocol):
identity_id: str,
*,
tenant_id: str | None = None,
effective_at: datetime | None = None,
) -> Sequence[OrganizationFunctionAssignmentRef]:
...
@@ -48,5 +138,153 @@ 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 IdmRelationshipDirectory(Protocol):
"""Tenant-safe forward/reverse lookup for typed IDM relationships."""
def get_typed_group(
self,
group_id: str,
*,
tenant_id: str | None = None,
) -> TypedGroupRef | None:
...
def list_typed_groups(
self,
*,
tenant_id: str,
query: str | None = None,
group_types: Sequence[str] = (),
include_inactive: bool = False,
limit: int = 100,
) -> Sequence[TypedGroupRef]:
...
def identity_relationships_for_identity(
self,
identity_id: str,
*,
tenant_id: str,
effective_at: datetime | None = None,
relationship_kinds: Sequence[str] = (),
) -> Sequence[IdentityRelationshipRef]:
...
def identity_relationships_for_identities(
self,
identity_ids: Sequence[str],
*,
tenant_id: str,
effective_at: datetime | None = None,
relationship_kinds: Sequence[str] = (),
) -> Mapping[str, Sequence[IdentityRelationshipRef]]:
...
def identity_relationships_for_group(
self,
group_id: str,
*,
tenant_id: str,
effective_at: datetime | None = None,
relationship_kinds: Sequence[str] = (),
) -> Sequence[IdentityRelationshipRef]:
...
def identity_relationships_for_groups(
self,
group_ids: Sequence[str],
*,
tenant_id: str,
effective_at: datetime | None = None,
relationship_kinds: Sequence[str] = (),
) -> Mapping[str, Sequence[IdentityRelationshipRef]]:
...
def resolve_typed_group_memberships(
self,
group_ids: Sequence[str],
*,
tenant_id: str,
effective_at: datetime | None = None,
relationship_kinds: Sequence[str] = ("member",),
) -> Mapping[str, TypedGroupMembershipResolutionRef]:
...
@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
)

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