Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cb2080938 | ||
|
|
08c3e47b6d | ||
|
|
6e518fa6a2 | ||
|
|
f98cf9ced8 | ||
|
|
d2e491348d | ||
|
|
562d278f60 | ||
|
|
c6f6faf64f | ||
|
|
1c3ee9e8c7 | ||
|
|
aa91063211 | ||
|
|
fa2d5d40dd | ||
|
|
6ccef162f6 | ||
|
|
48dac139a5 | ||
|
|
a090e5af20 | ||
|
|
0c1358b862 | ||
|
|
a9035c4c3b | ||
|
|
137c7c005f | ||
|
|
8eeea968f2 | ||
|
|
0ca6568005 | ||
|
|
af90db44c9 | ||
|
|
5de46e9c0e | ||
|
|
1d9b677c1b | ||
|
|
54178ee56c | ||
|
|
10e7597612 | ||
|
|
142ccbc587 | ||
|
|
f75ad48d78 | ||
|
|
5a9e8f79f9 | ||
|
|
fbea74a74b | ||
|
|
925dc33696 | ||
|
|
4b0737e1cd | ||
|
|
4f4007aff1 | ||
|
|
8e687c4420 | ||
|
|
604f20eed7 | ||
|
|
6a2da94e47 | ||
|
|
e121ca900e | ||
|
|
79629c5a2c | ||
|
|
026e451aa4 | ||
|
|
f11c675d11 | ||
|
|
0fae09ba3c | ||
|
|
8f642bd618 | ||
|
|
6643c8fc1e | ||
|
|
fd90b60430 | ||
|
|
0aae6f0539 | ||
|
|
be7b79612c | ||
|
|
557c77670b | ||
|
|
d277218784 | ||
|
|
cf16a7b27a | ||
|
|
51bf14f376 | ||
|
|
8d9bcfd8b5 | ||
|
|
3c7a593f63 | ||
|
|
9d1352ba30 | ||
|
|
4cf2bfeb3e | ||
|
|
94c94fefb4 | ||
|
|
d600bca374 | ||
|
|
ffaab543d2 | ||
|
|
41db78c201 | ||
|
|
c042244da8 | ||
|
|
5a2e99f496 | ||
|
|
8a925782ab | ||
|
|
7685a103e8 | ||
|
|
ee5c881df9 | ||
|
|
6814a41ae4 | ||
|
|
dd7ad4d9c7 | ||
|
|
934db6d44b | ||
|
|
d307e29145 | ||
|
|
ff88142471 | ||
|
|
887e9beb9e | ||
|
|
e6457b3f6b | ||
|
|
eb0c01c5d2 | ||
|
|
40cc012124 | ||
|
|
44196f5620 | ||
|
|
9ceb1b8c22 | ||
|
|
32c234fbdb | ||
|
|
d65d7a8e5f | ||
|
|
b5f5be15f6 | ||
|
|
f5949427cc | ||
|
|
5d1287735e | ||
|
|
7ea0cb8655 | ||
|
|
b553513c9f | ||
|
|
b5a4eb177a | ||
|
|
f1a5be2a93 | ||
|
|
add7a99f6d | ||
|
|
982ef636b8 | ||
|
|
9aad49f16d | ||
|
|
2b5c14385d | ||
|
|
bca3e46293 | ||
|
|
f09d2bf9df | ||
|
|
702421be48 | ||
|
|
bb471df21c | ||
|
|
bfb0d7d7c9 | ||
|
|
1974bf1a2b | ||
|
|
0c9bf6758c | ||
|
|
25da7d49a9 | ||
|
|
40c10089ab | ||
|
|
d6e7c8b0b1 | ||
|
|
5bc7d748f8 | ||
|
|
7117673ecc | ||
|
|
14351b0c94 | ||
|
|
ad57fad1ea | ||
|
|
fa32cca03f | ||
|
|
2d0551a845 | ||
|
|
bb84122061 | ||
|
|
b823a22b9b | ||
|
|
70fc6da811 | ||
|
|
729b84d3af | ||
|
|
b962f6756e | ||
|
|
79d00b84e3 | ||
|
|
842be5edb5 | ||
|
|
7e59a7f2b3 | ||
|
|
5bfbe9a887 | ||
|
|
01f91154e0 | ||
|
|
6c2940aebc | ||
|
|
670693bde8 | ||
|
|
bca6a7c8aa | ||
|
|
21c1fa49b6 | ||
|
|
435b924fd9 | ||
|
|
af5c6af0e7 | ||
|
|
c6ef644842 | ||
|
|
5783d43547 | ||
|
|
e4d2d10c7e | ||
|
|
fe62fd4644 | ||
|
|
9ecdc6d713 | ||
|
|
ca35aad286 | ||
|
|
d6255f9f8f | ||
|
|
b58c9c55cf | ||
|
|
3c4bcc28f1 | ||
|
|
972c681650 | ||
|
|
2b4eb0151f | ||
|
|
7192d32e65 | ||
|
|
b65b48832b | ||
|
|
4cb334c912 | ||
|
|
6ebb299d6c | ||
|
|
42b5019464 | ||
|
|
4c0e6435ab | ||
|
|
e37d8fee94 | ||
|
|
50a8d459e7 | ||
|
|
5ee85d07d6 | ||
|
|
cf01545806 | ||
|
|
1884274f8d | ||
|
|
5211e07d0b | ||
|
|
7b8072d049 | ||
|
|
5b55f59a92 | ||
|
|
f0898fcdee | ||
|
|
9b88ae388b | ||
|
|
6970bf7457 | ||
|
|
47e106684d | ||
|
|
9e219bc4d3 | ||
|
|
ea436a513f | ||
|
|
e7c84e3227 | ||
|
|
cf7afe9dda | ||
|
|
ca8a8c5111 | ||
|
|
f3b388fe7e | ||
|
|
af3e0a055d | ||
|
|
51d4032b86 | ||
|
|
0beb9ffea9 | ||
|
|
9e6a6b5fdc | ||
|
|
48fb953b93 | ||
|
|
4bde0495f7 | ||
|
|
a80caf7933 | ||
|
|
790790ab37 | ||
|
|
920e3c9834 | ||
|
|
13893c80cd | ||
|
|
a192a2215f | ||
|
|
e8fed6d25a | ||
|
|
d9b5708df0 | ||
|
|
68328f3d8e | ||
|
|
53e947935a | ||
|
|
324c26da78 | ||
|
|
389f98e349 | ||
|
|
ce9ef8d88f | ||
|
|
13bc3d3b4e | ||
|
|
3f5870281a | ||
|
|
a46df85479 | ||
|
|
c31581b1b9 | ||
|
|
26ae034153 | ||
|
|
baa2143a26 | ||
|
|
8b1910b5b7 | ||
|
|
d36bb94335 | ||
|
|
74034947c6 | ||
|
|
c7183fe7f1 | ||
|
|
139a352c80 | ||
|
|
336c94137f | ||
|
|
93225b6487 | ||
|
|
e11ea81008 | ||
|
|
bc8afeb139 | ||
|
|
f876345656 | ||
|
|
d487726f4d | ||
|
|
e6fc07da37 | ||
|
|
e6d589eb07 | ||
|
|
59610e21d2 | ||
|
|
cece71d945 | ||
|
|
22e8183846 | ||
|
|
aa111a5fe1 | ||
|
|
e6062fe9e4 | ||
|
|
987ca894ed | ||
|
|
4caa326878 | ||
|
|
8c4c4456c6 | ||
|
|
6abe292ac8 | ||
|
|
fea2807754 | ||
|
|
22646c614c | ||
|
|
17376332a2 | ||
|
|
0946bc84a9 | ||
|
|
a18499cbb5 | ||
|
|
36d7b73bb5 | ||
|
|
b89a2d15f1 | ||
|
|
7f923afdad | ||
|
|
a7683c5d4a | ||
|
|
41ad057f7e | ||
|
|
bf0729eb59 | ||
|
|
c4b90181e0 | ||
|
|
55ed194a99 | ||
|
|
b3b0cf0fca | ||
|
|
fa9119bea7 | ||
|
|
70ca772138 | ||
|
|
2eae5c4df6 | ||
|
|
57fe6c6006 | ||
|
|
713afdb39b | ||
|
|
77f8d15d17 | ||
|
|
fda99d40eb | ||
|
|
5ab1af803b | ||
|
|
0845e99cf6 | ||
|
|
28a0a596a6 | ||
|
|
ae74189588 | ||
|
|
09b5009187 | ||
|
|
2ca61059dc | ||
|
|
865901f090 | ||
|
|
2ac1e64daa | ||
|
|
7526c5ebb2 | ||
|
|
8e1f64c790 | ||
|
|
66e4783d2e | ||
|
|
7af86b42eb | ||
|
|
ad202f1267 | ||
|
|
6526f37aae | ||
|
|
9dabd9356d | ||
|
|
6502775bf7 | ||
|
|
b2492b820f | ||
|
|
78d9ae48b2 | ||
|
|
4cb3e94de3 | ||
|
|
9131838b98 | ||
|
|
8e9eb6e1f5 | ||
|
|
249bf63eb8 | ||
|
|
248e3dc70e | ||
|
|
230ecf42b0 | ||
|
|
825791e9b0 | ||
|
|
7184b6cdd6 | ||
|
|
ea8c600dce | ||
|
|
1839693575 | ||
|
|
183bf7aef0 | ||
|
|
37a5dfb182 | ||
|
|
844f934379 | ||
|
|
a98475f7bc | ||
|
|
1153c9dd36 | ||
|
|
7eef52776c | ||
|
|
c50ce58ad8 | ||
|
|
344fc0077f | ||
|
|
28afc01371 | ||
|
|
1a29e75db4 | ||
|
|
57ec960f40 | ||
|
|
6388afdad8 | ||
|
|
1a0e90b22d | ||
|
|
7ad6f6328a | ||
|
|
c79a7124b7 | ||
|
|
abbef5a10b | ||
|
|
2f559e3f0b | ||
|
|
9b5418db78 | ||
|
|
1678602fd6 | ||
|
|
6e373dcdd6 | ||
|
|
d1c033edc7 | ||
|
|
b5cfba666c | ||
|
|
78b4afdec4 | ||
|
|
15596f0742 | ||
|
|
a2320fcb5d | ||
|
|
e6f7c45f0a | ||
|
|
8aa1943581 | ||
|
|
b9badc9153 | ||
|
|
9a0c467d55 | ||
|
|
a00ef54821 | ||
|
|
edb4687826 | ||
|
|
fcfe0b69a3 | ||
|
|
715bdcbebe | ||
|
|
060f4da751 | ||
|
|
c32951393a | ||
|
|
7b85d6deae | ||
|
|
2d2d9e7bc7 | ||
|
|
dc1a250797 | ||
|
|
7b7cc8ada7 | ||
|
|
722c9e5d1c | ||
|
|
63e54a67be | ||
|
|
12b623bec9 | ||
|
|
b788afcae1 | ||
|
|
2b0cdf13f3 | ||
|
|
013e0b883d | ||
|
|
50f60e4d43 | ||
|
|
ff4b514475 | ||
|
|
ca1206e8c9 | ||
|
|
94236a7d7e | ||
|
|
8dd5123aab | ||
|
|
c61abe154c | ||
|
|
83784ea19d | ||
|
|
81f0f649b5 | ||
|
|
0a130962d3 | ||
|
|
79af252e88 | ||
|
|
635d25c74c | ||
|
|
150b720f12 | ||
|
|
a2053518d1 | ||
|
|
6d391d13bd | ||
|
|
df701fddd2 | ||
|
|
02564047e9 | ||
|
|
15794e920e |
@@ -0,0 +1,28 @@
|
|||||||
|
.git
|
||||||
|
.venv
|
||||||
|
node_modules
|
||||||
|
webui/node_modules
|
||||||
|
audit-reports
|
||||||
|
runtime
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
coverage
|
||||||
|
htmlcov
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.log
|
||||||
|
.mypy_cache
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
.cache
|
||||||
|
.component-test-build
|
||||||
|
.module-test-build
|
||||||
|
.policy-test-build
|
||||||
|
.template-preview-test-build
|
||||||
|
.import-test-build
|
||||||
|
webui/.component-test-build
|
||||||
|
webui/.module-test-build
|
||||||
|
webui/.policy-test-build
|
||||||
|
webui/.template-preview-test-build
|
||||||
|
webui/.import-test-build
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
name: "Bug"
|
||||||
|
about: "Report a reproducible defect, regression, or incorrect behavior"
|
||||||
|
title: "[Bug] "
|
||||||
|
labels:
|
||||||
|
- type/bug
|
||||||
|
- status/triage
|
||||||
|
- module/core
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Repository:
|
||||||
|
- Area/module:
|
||||||
|
- Affected version or commit:
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
Actual:
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
Logs, screenshots, traces, or failing test output:
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
Command or workflow that should pass when fixed:
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: "Docs / workflow"
|
||||||
|
about: "Request documentation, process, or developer workflow changes"
|
||||||
|
title: "[Docs] "
|
||||||
|
labels:
|
||||||
|
- type/docs
|
||||||
|
- status/triage
|
||||||
|
- module/core
|
||||||
|
- area/docs
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Repository:
|
||||||
|
- Document or workflow:
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
What is missing, unclear, duplicated, or stale?
|
||||||
|
|
||||||
|
## Desired State
|
||||||
|
|
||||||
|
What should the docs or workflow make clear?
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
How should this be checked?
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
name: "Feature"
|
||||||
|
about: "Propose new user-visible behavior or platform capability"
|
||||||
|
title: "[Feature] "
|
||||||
|
labels:
|
||||||
|
- type/feature
|
||||||
|
- status/triage
|
||||||
|
- module/core
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
What user, operator, or developer problem should this solve?
|
||||||
|
|
||||||
|
## Proposed Capability
|
||||||
|
|
||||||
|
What should exist when this is done?
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
- Owning repository:
|
||||||
|
- Related module repositories:
|
||||||
|
- Extension point or integration boundary:
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ]
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
Command, scenario, or UI flow that should prove completion:
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
name: "Task"
|
||||||
|
about: "Track implementation, maintenance, or migration work"
|
||||||
|
title: "[Task] "
|
||||||
|
labels:
|
||||||
|
- type/task
|
||||||
|
- status/triage
|
||||||
|
- module/core
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
What needs to be completed?
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Owning repository:
|
||||||
|
- In-scope:
|
||||||
|
- Out-of-scope:
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ]
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
Command or manual check:
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
name: "Tech debt"
|
||||||
|
about: "Track cleanup, refactoring, risk reduction, or deferred engineering work"
|
||||||
|
title: "[Debt] "
|
||||||
|
labels:
|
||||||
|
- type/debt
|
||||||
|
- status/triage
|
||||||
|
- module/core
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Cost
|
||||||
|
|
||||||
|
What does this make harder, riskier, slower, or more fragile?
|
||||||
|
|
||||||
|
## Desired Shape
|
||||||
|
|
||||||
|
What should the code, tests, or architecture look like afterwards?
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
What behavior, compatibility, or module boundary must be preserved?
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
Focused checks that should pass:
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## Issue
|
||||||
|
|
||||||
|
Closes #
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Follow-up issues:
|
||||||
@@ -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
|
||||||
+27
@@ -136,6 +136,25 @@ dist
|
|||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
|
# Local WebUI test/build scratch directories
|
||||||
|
.component-test-build/
|
||||||
|
.file-drop-test-build/
|
||||||
|
.module-test-build/
|
||||||
|
.policy-test-build/
|
||||||
|
.template-preview-test-build/
|
||||||
|
.import-test-build/
|
||||||
|
webui/.component-test-build/
|
||||||
|
webui/.file-drop-test-build/
|
||||||
|
webui/.module-test-build/
|
||||||
|
webui/.policy-test-build/
|
||||||
|
webui/.template-preview-test-build/
|
||||||
|
webui/.import-test-build/
|
||||||
|
webui/dist-conformance/
|
||||||
|
webui/test-results/
|
||||||
|
|
||||||
|
# Security audit reports
|
||||||
|
audit-reports/
|
||||||
|
|
||||||
# ---> Python
|
# ---> Python
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
@@ -326,3 +345,11 @@ cython_debug/
|
|||||||
# Built Visual Studio Code Extensions
|
# Built Visual Studio Code Extensions
|
||||||
*.vsix
|
*.vsix
|
||||||
|
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# GovOPlaN local runtime state
|
||||||
|
runtime/
|
||||||
|
|
||||||
|
# GovOPlaN WebUI test output
|
||||||
|
webui/.module-test-build/
|
||||||
|
webui/.component-test-build/
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# GovOPlaN Codex Guide
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This repository is the platform runner and shared core for GovOPlaN. It owns the server entry point, database/session primitives, auth, tenancy, RBAC, governance, module discovery, migrations, shared WebUI shell, and generic WebUI components.
|
||||||
|
|
||||||
|
Sibling module repositories usually used with this repo:
|
||||||
|
|
||||||
|
- `/mnt/DATA/git/govoplan-access`
|
||||||
|
- `/mnt/DATA/git/govoplan-files`
|
||||||
|
- `/mnt/DATA/git/govoplan-mail`
|
||||||
|
- `/mnt/DATA/git/govoplan-campaign`
|
||||||
|
|
||||||
|
Keep module-specific behavior in the owning module. Core may expose stable extension points, capabilities, and shared components, but should not directly import module WebUI pages or require optional module packages for core-only startup.
|
||||||
|
|
||||||
|
## Local Commands
|
||||||
|
|
||||||
|
Use targeted commands first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan-core
|
||||||
|
/mnt/DATA/git/govoplan/.venv/bin/python -m govoplan_core.devserver --smoke --no-reload
|
||||||
|
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest tests.test_module_system
|
||||||
|
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest tests.test_api_smoke.ApiSmokeTests.test_mailbox_message_listing_reports_total_count
|
||||||
|
```
|
||||||
|
|
||||||
|
For WebUI checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan-core/webui
|
||||||
|
PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:mail-components
|
||||||
|
PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:module-capabilities
|
||||||
|
PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:module-permutations
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the consolidated focused check when a change touches module discovery, optional integrations, shared mail components, or mailbox listing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
tools/checks/check-focused.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Working Rules
|
||||||
|
|
||||||
|
- 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.
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
# govoplan-core
|
# govoplan-core
|
||||||
|
|
||||||
GovOPlaN core is the platform runner and shared foundation. It owns the server entry point, database/session primitives, tenant and RBAC infrastructure, governance policy, audit/auth helpers, module discovery, migration registration, and the shared WebUI shell. Feature code is supplied by installed modules.
|
<!-- govoplan-repository-type:start -->
|
||||||
|
**Repository type:** system (kernel).
|
||||||
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
|
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
|
## Repository ownership
|
||||||
|
|
||||||
@@ -9,46 +13,134 @@ Core owns:
|
|||||||
- `govoplan_core.server.app:app`, the FastAPI entry point used by uvicorn
|
- `govoplan_core.server.app:app`, the FastAPI entry point used by uvicorn
|
||||||
- `GovoplanServerConfig`, module discovery, registry validation, and route aggregation
|
- `GovoplanServerConfig`, module discovery, registry validation, and route aggregation
|
||||||
- SQLAlchemy base/session helpers and module migration registration
|
- SQLAlchemy base/session helpers and module migration registration
|
||||||
- tenant/account/session/RBAC/governance/audit models and services
|
- kernel APIs for platform metadata, module lifecycle, health, and development diagnostics
|
||||||
- core API routes for auth, admin, platform metadata, audit, and system health
|
|
||||||
- `@govoplan/core-webui`, including login, CSRF/API helpers, shell layout, generic UI components, IconRail, DataGrid, access boundaries, and module route/nav contracts
|
- `@govoplan/core-webui`, including login, CSRF/API helpers, shell layout, generic UI components, IconRail, DataGrid, access boundaries, and module route/nav contracts
|
||||||
|
|
||||||
Feature modules own their backend routers, models, migrations, permissions, frontend packages, nav items, and route contributions. Core should not import feature pages directly; it imports module manifests and renders their route contributions.
|
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
|
||||||
|
modules. Core should not import feature pages directly; it imports module
|
||||||
|
manifests and renders their route contributions.
|
||||||
|
|
||||||
## Governance docs
|
## Governance docs
|
||||||
|
|
||||||
Canonical policy documents live in `docs/`:
|
Canonical policy documents live in `docs/`:
|
||||||
|
|
||||||
- [RBAC_MANIFEST.md](docs/RBAC_MANIFEST.md)
|
- [DOCUMENTATION_MAP.md](docs/DOCUMENTATION_MAP.md)
|
||||||
- [SYSTEM_GOVERNANCE_MANIFEST.md](docs/SYSTEM_GOVERNANCE_MANIFEST.md)
|
- [ACCESS_RBAC_MODEL.md](docs/ACCESS_RBAC_MODEL.md)
|
||||||
|
- [GOVERNANCE_MODEL.md](docs/GOVERNANCE_MODEL.md)
|
||||||
- [MODULE_ARCHITECTURE.md](docs/MODULE_ARCHITECTURE.md)
|
- [MODULE_ARCHITECTURE.md](docs/MODULE_ARCHITECTURE.md)
|
||||||
|
- [DEPLOYMENT_OPERATOR_GUIDE.md](docs/DEPLOYMENT_OPERATOR_GUIDE.md)
|
||||||
|
- [CODEX_WORKFLOW.md](docs/CODEX_WORKFLOW.md)
|
||||||
|
|
||||||
Modules may define module-specific permissions and policy behavior, but the platform-level permission model and governance hierarchy belong here.
|
Modules define module-specific permissions and policy behavior. Shared DTOs and
|
||||||
|
composition rules live in core only where they are stable kernel contracts.
|
||||||
|
|
||||||
## Backend development
|
## Backend development
|
||||||
|
|
||||||
Create or activate the core virtual environment, then install core and sibling modules from this repository:
|
For whole-product development, create the virtualenv from the meta repository:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan-core
|
cd /mnt/DATA/git/govoplan
|
||||||
|
python3 -m venv .venv
|
||||||
|
./.venv/bin/python -m pip install --upgrade pip
|
||||||
./.venv/bin/python -m pip install -r requirements-dev.txt
|
./.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 loads the access/core module only; product configs can enable installed modules.
|
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
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan-core
|
cd /mnt/DATA/git/govoplan-core
|
||||||
./.venv/bin/python -m govoplan_core.devserver \
|
/mnt/DATA/git/govoplan/.venv/bin/python -m govoplan_core.devserver \
|
||||||
--config app.govoplan_config:get_server_config \
|
--host 127.0.0.1 \
|
||||||
|
--port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
For example, to test campaign without files or mail:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan-core
|
||||||
|
ENABLED_MODULES=access,campaigns /mnt/DATA/git/govoplan/.venv/bin/python -m govoplan_core.devserver \
|
||||||
--host 127.0.0.1 \
|
--host 127.0.0.1 \
|
||||||
--port 8000
|
--port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
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`.
|
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.
|
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.
|
||||||
|
|
||||||
`requirements-dev.txt` links local `govoplan-files`, `govoplan-mail`, and `govoplan-campaign` checkouts for development. `requirements-release.txt` installs those modules from tagged git refs for release builds. See [RELEASE_DEPENDENCIES.md](docs/RELEASE_DEPENDENCIES.md).
|
To run the production-like local profile with PostgreSQL, Redis, a Celery
|
||||||
|
worker, explicit module configuration, and persistent local file storage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
tools/launch/launch-production-like-dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
See `/mnt/DATA/git/govoplan/dev/production-like/README.md` for ports,
|
||||||
|
environment overrides, and cleanup commands. Core keeps wrapper commands during
|
||||||
|
the migration, but whole-product profiles are owned by the meta repository.
|
||||||
|
|
||||||
|
## Security audit
|
||||||
|
|
||||||
|
The repository includes a containerized audit toolbox for SAST, secret scanning,
|
||||||
|
dependency checks, filesystem misconfiguration scans, duplication, and complexity
|
||||||
|
reports. See [SECURITY_AUDIT.md](docs/SECURITY_AUDIT.md) for the operating model.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
tools/checks/security-audit/run.sh --mode ci --scope govoplan
|
||||||
|
tools/checks/security-audit/run.sh --mode full --scope govoplan
|
||||||
|
```
|
||||||
|
|
||||||
|
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 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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan-core
|
||||||
|
/mnt/DATA/git/govoplan/.venv/bin/python -m govoplan_core.devserver --smoke --no-reload
|
||||||
|
```
|
||||||
|
|
||||||
|
The smoke mode prints the effective config, runtime root, database URL, modules, reload state, and bootstrap decision, then creates the ASGI app and runs startup once.
|
||||||
|
|
||||||
|
The meta repository owns whole-product `requirements-dev.txt`,
|
||||||
|
`requirements-release.txt`, and the root `.env.example` operator template. Core
|
||||||
|
keeps package metadata and runtime commands. See
|
||||||
|
[RELEASE_DEPENDENCIES.md](docs/RELEASE_DEPENDENCIES.md).
|
||||||
|
|
||||||
|
For the install/runtime configuration contract and operator deployment flow, see [DEPLOYMENT_OPERATOR_GUIDE.md](docs/DEPLOYMENT_OPERATOR_GUIDE.md).
|
||||||
|
For self-hosted config bootstrap and validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan-core
|
||||||
|
/mnt/DATA/git/govoplan/.venv/bin/python -m govoplan_core.commands.config env-template --profile self-hosted --generate-secrets
|
||||||
|
/mnt/DATA/git/govoplan/.venv/bin/python -m govoplan_core.commands.config validate --profile self-hosted
|
||||||
|
```
|
||||||
|
|
||||||
## WebUI development
|
## WebUI development
|
||||||
|
|
||||||
@@ -62,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).
|
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
|
## Module contract
|
||||||
|
|
||||||
Backend modules register through the `govoplan.modules` entry point and return a `ModuleManifest`. A manifest can contribute:
|
Backend modules register through the `govoplan.modules` entry point and return a `ModuleManifest`. A manifest can contribute:
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,8 @@
|
|||||||
[alembic]
|
[alembic]
|
||||||
script_location = alembic
|
script_location = alembic
|
||||||
|
path_separator = os
|
||||||
prepend_sys_path = .
|
prepend_sys_path = .
|
||||||
sqlalchemy.url = sqlite:///./multimailer-dev.db
|
sqlalchemy.url = sqlite:///./runtime/govoplan-dev.db
|
||||||
|
|
||||||
[loggers]
|
[loggers]
|
||||||
keys = root,sqlalchemy,alembic
|
keys = root,sqlalchemy,alembic
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""split mail profile usernames from server config
|
||||||
|
|
||||||
|
Revision ID: 0a1b2c3d4e6f
|
||||||
|
Revises: f5a6b7c8d9e0
|
||||||
|
Create Date: 2026-06-25 15:20:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0a1b2c3d4e6f"
|
||||||
|
down_revision = "f5a6b7c8d9e0"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _profiles_table() -> sa.Table:
|
||||||
|
return sa.table(
|
||||||
|
"mail_server_profiles",
|
||||||
|
sa.column("id", sa.String(length=36)),
|
||||||
|
sa.column("smtp_config", sa.JSON()),
|
||||||
|
sa.column("smtp_username", sa.String(length=320)),
|
||||||
|
sa.column("imap_config", sa.JSON()),
|
||||||
|
sa.column("imap_username", sa.String(length=320)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _without_username(value):
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return value, None
|
||||||
|
data = dict(value)
|
||||||
|
username = data.pop("username", None)
|
||||||
|
data.pop("enabled", None)
|
||||||
|
return data, username
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if "mail_server_profiles" not in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
columns = {column["name"] for column in inspector.get_columns("mail_server_profiles")}
|
||||||
|
if "smtp_username" not in columns:
|
||||||
|
op.add_column("mail_server_profiles", sa.Column("smtp_username", sa.String(length=320), nullable=True))
|
||||||
|
if "imap_username" not in columns:
|
||||||
|
op.add_column("mail_server_profiles", sa.Column("imap_username", sa.String(length=320), nullable=True))
|
||||||
|
|
||||||
|
profiles = _profiles_table()
|
||||||
|
rows = bind.execute(sa.select(profiles.c.id, profiles.c.smtp_config, profiles.c.smtp_username, profiles.c.imap_config, profiles.c.imap_username)).mappings().all()
|
||||||
|
for row in rows:
|
||||||
|
smtp_config, smtp_username = _without_username(row["smtp_config"] or {})
|
||||||
|
imap_config, imap_username = _without_username(row["imap_config"] or {}) if row["imap_config"] is not None else (None, None)
|
||||||
|
values = {
|
||||||
|
"smtp_config": smtp_config,
|
||||||
|
"imap_config": imap_config,
|
||||||
|
}
|
||||||
|
if row["smtp_username"] in (None, "") and smtp_username not in (None, ""):
|
||||||
|
values["smtp_username"] = str(smtp_username)
|
||||||
|
if row["imap_username"] in (None, "") and imap_username not in (None, ""):
|
||||||
|
values["imap_username"] = str(imap_username)
|
||||||
|
bind.execute(sa.update(profiles).where(profiles.c.id == row["id"]).values(**values))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if "mail_server_profiles" not in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
columns = {column["name"] for column in inspector.get_columns("mail_server_profiles")}
|
||||||
|
profiles = _profiles_table()
|
||||||
|
if {"smtp_username", "imap_username"}.issubset(columns):
|
||||||
|
rows = bind.execute(sa.select(profiles.c.id, profiles.c.smtp_config, profiles.c.smtp_username, profiles.c.imap_config, profiles.c.imap_username)).mappings().all()
|
||||||
|
for row in rows:
|
||||||
|
smtp_config = dict(row["smtp_config"] or {})
|
||||||
|
if row["smtp_username"] not in (None, ""):
|
||||||
|
smtp_config["username"] = row["smtp_username"]
|
||||||
|
imap_config = dict(row["imap_config"] or {}) if row["imap_config"] is not None else None
|
||||||
|
if imap_config is not None and row["imap_username"] not in (None, ""):
|
||||||
|
imap_config["username"] = row["imap_username"]
|
||||||
|
bind.execute(sa.update(profiles).where(profiles.c.id == row["id"]).values(smtp_config=smtp_config, imap_config=imap_config))
|
||||||
|
if "imap_username" in columns:
|
||||||
|
op.drop_column("mail_server_profiles", "imap_username")
|
||||||
|
if "smtp_username" in columns:
|
||||||
|
op.drop_column("mail_server_profiles", "smtp_username")
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""add audit outbox events to detailed dev track
|
||||||
|
|
||||||
|
Revision ID: 0f1e2d3c4b5a
|
||||||
|
Revises: 4f2a9c8e7b6d
|
||||||
|
Create Date: 2026-07-11 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0f1e2d3c4b5a"
|
||||||
|
down_revision = "4f2a9c8e7b6d"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"audit_outbox_events",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("event_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("event_type", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("module_id", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("correlation_id", sa.String(length=128), nullable=True),
|
||||||
|
sa.Column("causation_id", sa.String(length=128), nullable=True),
|
||||||
|
sa.Column("classification", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("payload", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("attempts", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("dispatched_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_error", 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_audit_outbox_events")),
|
||||||
|
sa.UniqueConstraint("event_id", name="uq_audit_outbox_events_event_id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_audit_outbox_events_correlation_id", "audit_outbox_events", ["correlation_id"], unique=False)
|
||||||
|
op.create_index("ix_audit_outbox_events_event_type", "audit_outbox_events", ["event_type"], unique=False)
|
||||||
|
op.create_index(op.f("ix_audit_outbox_events_status"), "audit_outbox_events", ["status"], unique=False)
|
||||||
|
op.create_index(
|
||||||
|
"ix_audit_outbox_events_status_next_attempt_at",
|
||||||
|
"audit_outbox_events",
|
||||||
|
["status", "next_attempt_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("audit_outbox_events")
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""drop mail credential override policy fields
|
||||||
|
|
||||||
|
Revision ID: 1b2c3d4e5f70
|
||||||
|
Revises: 0a1b2c3d4e6f
|
||||||
|
Create Date: 2026-06-25 18:30:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "1b2c3d4e5f70"
|
||||||
|
down_revision = "0a1b2c3d4e6f"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_MAIL_POLICY_KEY = "mail_profile_policy"
|
||||||
|
_CREDENTIAL_KEYS = ("smtp_credentials", "imap_credentials")
|
||||||
|
_DEPRECATED_LIMIT_KEYS = ("smtp_credentials.allow_override", "imap_credentials.allow_override")
|
||||||
|
|
||||||
|
|
||||||
|
def _json_table(table_name: str, column_name: str) -> sa.Table:
|
||||||
|
return sa.table(
|
||||||
|
table_name,
|
||||||
|
sa.column("id", sa.String(length=36)),
|
||||||
|
sa.column(column_name, sa.JSON()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_dict(value: Any) -> dict[str, Any] | None:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return dict(value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
return dict(parsed) if isinstance(parsed, dict) else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _scrub_policy(value: Any) -> tuple[dict[str, Any] | None, bool]:
|
||||||
|
policy = _as_dict(value)
|
||||||
|
if policy is None:
|
||||||
|
return None, False
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
for key in _CREDENTIAL_KEYS:
|
||||||
|
credential = _as_dict(policy.get(key))
|
||||||
|
if credential is not None and "allow_override" in credential:
|
||||||
|
credential.pop("allow_override", None)
|
||||||
|
policy[key] = credential
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
limits = _as_dict(policy.get("allow_lower_level_limits"))
|
||||||
|
if limits is not None:
|
||||||
|
for key in _DEPRECATED_LIMIT_KEYS:
|
||||||
|
if key in limits:
|
||||||
|
limits.pop(key, None)
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
policy["allow_lower_level_limits"] = limits
|
||||||
|
|
||||||
|
return policy, changed
|
||||||
|
|
||||||
|
|
||||||
|
def _scrub_settings_table(table_name: str) -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
table = _json_table(table_name, "settings")
|
||||||
|
rows = bind.execute(sa.select(table.c.id, table.c.settings)).mappings().all()
|
||||||
|
for row in rows:
|
||||||
|
settings = _as_dict(row["settings"])
|
||||||
|
if settings is None:
|
||||||
|
continue
|
||||||
|
policy, changed = _scrub_policy(settings.get(_MAIL_POLICY_KEY))
|
||||||
|
if changed and policy is not None:
|
||||||
|
settings[_MAIL_POLICY_KEY] = policy
|
||||||
|
bind.execute(sa.update(table).where(table.c.id == row["id"]).values(settings=settings))
|
||||||
|
|
||||||
|
|
||||||
|
def _scrub_policy_column(table_name: str) -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
table = _json_table(table_name, "mail_profile_policy")
|
||||||
|
rows = bind.execute(sa.select(table.c.id, table.c.mail_profile_policy)).mappings().all()
|
||||||
|
for row in rows:
|
||||||
|
policy, changed = _scrub_policy(row["mail_profile_policy"])
|
||||||
|
if changed and policy is not None:
|
||||||
|
bind.execute(sa.update(table).where(table.c.id == row["id"]).values(mail_profile_policy=policy))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
for table_name in ("core_system_settings", "tenancy_tenants"):
|
||||||
|
if table_name in tables and "settings" in {column["name"] for column in inspector.get_columns(table_name)}:
|
||||||
|
_scrub_settings_table(table_name)
|
||||||
|
for table_name in ("access_users", "access_groups", "campaigns"):
|
||||||
|
if table_name in tables and "mail_profile_policy" in {column["name"] for column in inspector.get_columns(table_name)}:
|
||||||
|
_scrub_policy_column(table_name)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# The removed fields are redundant with the surviving lower-level limit
|
||||||
|
# switches, so they cannot be reconstructed safely.
|
||||||
|
pass
|
||||||
+1
-1
@@ -30,7 +30,7 @@ def upgrade() -> None:
|
|||||||
batch_op.add_column(sa.Column("locked_by_user_id", sa.String(length=36), nullable=True))
|
batch_op.add_column(sa.Column("locked_by_user_id", sa.String(length=36), nullable=True))
|
||||||
batch_op.create_foreign_key(
|
batch_op.create_foreign_key(
|
||||||
op.f("fk_campaign_versions_locked_by_user_id_users"),
|
op.f("fk_campaign_versions_locked_by_user_id_users"),
|
||||||
"users",
|
"access_users",
|
||||||
["locked_by_user_id"],
|
["locked_by_user_id"],
|
||||||
["id"],
|
["id"],
|
||||||
ondelete="SET NULL",
|
ondelete="SET NULL",
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""auth sessions and RBAC assignments
|
||||||
|
|
||||||
|
Revision ID: 2c3d4e5f6a7b
|
||||||
|
Revises: 1f8d4c2a0b7e
|
||||||
|
Create Date: 2026-06-08 10:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "2c3d4e5f6a7b"
|
||||||
|
down_revision = "1f8d4c2a0b7e"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("access_users") as batch_op:
|
||||||
|
batch_op.add_column(sa.Column("auth_provider", sa.String(length=50), nullable=False, server_default="local"))
|
||||||
|
batch_op.add_column(sa.Column("password_hash", sa.String(length=500), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"access_user_group_memberships",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("user_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("group_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["access_users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["group_id"], ["access_groups.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "user_id", "group_id", name="uq_user_group_memberships"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_access_user_group_memberships_tenant_id"), "access_user_group_memberships", ["tenant_id"])
|
||||||
|
op.create_index(op.f("ix_access_user_group_memberships_user_id"), "access_user_group_memberships", ["user_id"])
|
||||||
|
op.create_index(op.f("ix_access_user_group_memberships_group_id"), "access_user_group_memberships", ["group_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"access_user_role_assignments",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("user_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("role_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["access_users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["role_id"], ["access_roles.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "user_id", "role_id", name="uq_user_role_assignments"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_access_user_role_assignments_tenant_id"), "access_user_role_assignments", ["tenant_id"])
|
||||||
|
op.create_index(op.f("ix_access_user_role_assignments_user_id"), "access_user_role_assignments", ["user_id"])
|
||||||
|
op.create_index(op.f("ix_access_user_role_assignments_role_id"), "access_user_role_assignments", ["role_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"access_group_role_assignments",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("group_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("role_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["group_id"], ["access_groups.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["role_id"], ["access_roles.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "group_id", "role_id", name="uq_group_role_assignments"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_access_group_role_assignments_tenant_id"), "access_group_role_assignments", ["tenant_id"])
|
||||||
|
op.create_index(op.f("ix_access_group_role_assignments_group_id"), "access_group_role_assignments", ["group_id"])
|
||||||
|
op.create_index(op.f("ix_access_group_role_assignments_role_id"), "access_group_role_assignments", ["role_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"access_auth_sessions",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("user_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("token_hash", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("user_agent", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("ip_address", sa.String(length=100), 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"], ["tenancy_tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["access_users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("token_hash"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_access_auth_sessions_tenant_id"), "access_auth_sessions", ["tenant_id"])
|
||||||
|
op.create_index(op.f("ix_access_auth_sessions_user_id"), "access_auth_sessions", ["user_id"])
|
||||||
|
op.create_index(op.f("ix_access_auth_sessions_token_hash"), "access_auth_sessions", ["token_hash"])
|
||||||
|
op.create_index(op.f("ix_access_auth_sessions_expires_at"), "access_auth_sessions", ["expires_at"])
|
||||||
|
op.create_index(op.f("ix_access_auth_sessions_revoked_at"), "access_auth_sessions", ["revoked_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f("ix_access_auth_sessions_revoked_at"), table_name="access_auth_sessions")
|
||||||
|
op.drop_index(op.f("ix_access_auth_sessions_expires_at"), table_name="access_auth_sessions")
|
||||||
|
op.drop_index(op.f("ix_access_auth_sessions_token_hash"), table_name="access_auth_sessions")
|
||||||
|
op.drop_index(op.f("ix_access_auth_sessions_user_id"), table_name="access_auth_sessions")
|
||||||
|
op.drop_index(op.f("ix_access_auth_sessions_tenant_id"), table_name="access_auth_sessions")
|
||||||
|
op.drop_table("access_auth_sessions")
|
||||||
|
|
||||||
|
op.drop_index(op.f("ix_access_group_role_assignments_role_id"), table_name="access_group_role_assignments")
|
||||||
|
op.drop_index(op.f("ix_access_group_role_assignments_group_id"), table_name="access_group_role_assignments")
|
||||||
|
op.drop_index(op.f("ix_access_group_role_assignments_tenant_id"), table_name="access_group_role_assignments")
|
||||||
|
op.drop_table("access_group_role_assignments")
|
||||||
|
|
||||||
|
op.drop_index(op.f("ix_access_user_role_assignments_role_id"), table_name="access_user_role_assignments")
|
||||||
|
op.drop_index(op.f("ix_access_user_role_assignments_user_id"), table_name="access_user_role_assignments")
|
||||||
|
op.drop_index(op.f("ix_access_user_role_assignments_tenant_id"), table_name="access_user_role_assignments")
|
||||||
|
op.drop_table("access_user_role_assignments")
|
||||||
|
|
||||||
|
op.drop_index(op.f("ix_access_user_group_memberships_group_id"), table_name="access_user_group_memberships")
|
||||||
|
op.drop_index(op.f("ix_access_user_group_memberships_user_id"), table_name="access_user_group_memberships")
|
||||||
|
op.drop_index(op.f("ix_access_user_group_memberships_tenant_id"), table_name="access_user_group_memberships")
|
||||||
|
op.drop_table("access_user_group_memberships")
|
||||||
|
|
||||||
|
with op.batch_alter_table("access_users") as batch_op:
|
||||||
|
batch_op.drop_column("last_login_at")
|
||||||
|
batch_op.drop_column("password_hash")
|
||||||
|
batch_op.drop_column("auth_provider")
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""namespace platform-owned tables
|
||||||
|
|
||||||
|
Revision ID: 2e3f4a5b6c7d
|
||||||
|
Revises: 1b2c3d4e5f70
|
||||||
|
Create Date: 2026-07-09 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "2e3f4a5b6c7d"
|
||||||
|
down_revision = "1b2c3d4e5f70"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_TABLE_RENAMES = (
|
||||||
|
("tenants", "tenancy_tenants"),
|
||||||
|
("accounts", "access_accounts"),
|
||||||
|
("users", "access_users"),
|
||||||
|
("groups", "access_groups"),
|
||||||
|
("roles", "access_roles"),
|
||||||
|
("system_role_assignments", "access_system_role_assignments"),
|
||||||
|
("user_group_memberships", "access_user_group_memberships"),
|
||||||
|
("user_role_assignments", "access_user_role_assignments"),
|
||||||
|
("group_role_assignments", "access_group_role_assignments"),
|
||||||
|
("api_keys", "access_api_keys"),
|
||||||
|
("auth_sessions", "access_auth_sessions"),
|
||||||
|
("system_settings", "core_system_settings"),
|
||||||
|
("governance_templates", "admin_governance_templates"),
|
||||||
|
("governance_template_assignments", "admin_governance_template_assignments"),
|
||||||
|
)
|
||||||
|
_KNOWN_TABLE_NAMES = {name for pair in _TABLE_RENAMES for name in pair}
|
||||||
|
|
||||||
|
|
||||||
|
def _row_count(bind: sa.Connection, table_name: str) -> int:
|
||||||
|
if table_name not in _KNOWN_TABLE_NAMES:
|
||||||
|
raise RuntimeError(f"Unexpected table name: {table_name}")
|
||||||
|
quoted = bind.dialect.identifier_preparer.quote(table_name)
|
||||||
|
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one()) # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||||
|
|
||||||
|
|
||||||
|
def _rename_tables(renames: tuple[tuple[str, str], ...]) -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
|
||||||
|
old_tables_to_drop: set[str] = set()
|
||||||
|
new_tables_to_drop: set[str] = set()
|
||||||
|
for old_name, new_name in renames:
|
||||||
|
if old_name not in tables or new_name not in tables:
|
||||||
|
continue
|
||||||
|
if _row_count(bind, old_name) == 0:
|
||||||
|
old_tables_to_drop.add(old_name)
|
||||||
|
elif _row_count(bind, new_name) == 0:
|
||||||
|
new_tables_to_drop.add(new_name)
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"Cannot rename non-empty {old_name} over non-empty {new_name}")
|
||||||
|
|
||||||
|
for old_name, _new_name in reversed(renames):
|
||||||
|
if old_name in old_tables_to_drop:
|
||||||
|
op.drop_table(old_name)
|
||||||
|
tables.remove(old_name)
|
||||||
|
for _old_name, new_name in reversed(renames):
|
||||||
|
if new_name in new_tables_to_drop:
|
||||||
|
op.drop_table(new_name)
|
||||||
|
tables.remove(new_name)
|
||||||
|
|
||||||
|
for old_name, new_name in renames:
|
||||||
|
if old_name not in tables:
|
||||||
|
continue
|
||||||
|
op.rename_table(old_name, new_name)
|
||||||
|
tables.remove(old_name)
|
||||||
|
tables.add(new_name)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
_rename_tables(_TABLE_RENAMES)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
_rename_tables(tuple((new_name, old_name) for old_name, new_name in reversed(_TABLE_RENAMES)))
|
||||||
+10
-10
@@ -32,7 +32,7 @@ def upgrade() -> None:
|
|||||||
sa.Column("retained_until", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("retained_until", sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.PrimaryKeyConstraint("id"),
|
||||||
sa.UniqueConstraint("tenant_id", "checksum_sha256", "size_bytes", name="uq_file_blobs_tenant_checksum_size"),
|
sa.UniqueConstraint("tenant_id", "checksum_sha256", "size_bytes", name="uq_file_blobs_tenant_checksum_size"),
|
||||||
)
|
)
|
||||||
@@ -55,10 +55,10 @@ def upgrade() -> None:
|
|||||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||||
sa.ForeignKeyConstraint(["owner_group_id"], ["groups.id"], ondelete="SET NULL"),
|
sa.ForeignKeyConstraint(["owner_group_id"], ["access_groups.id"], ondelete="SET NULL"),
|
||||||
sa.ForeignKeyConstraint(["owner_user_id"], ["users.id"], ondelete="SET NULL"),
|
sa.ForeignKeyConstraint(["owner_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.PrimaryKeyConstraint("id"),
|
||||||
)
|
)
|
||||||
for col in ["tenant_id", "owner_type", "owner_user_id", "owner_group_id", "current_version_id", "display_path", "filename", "created_by_user_id", "deleted_at"]:
|
for col in ["tenant_id", "owner_type", "owner_user_id", "owner_group_id", "current_version_id", "display_path", "filename", "created_by_user_id", "deleted_at"]:
|
||||||
@@ -80,9 +80,9 @@ def upgrade() -> None:
|
|||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(["blob_id"], ["file_blobs.id"], ondelete="RESTRICT"),
|
sa.ForeignKeyConstraint(["blob_id"], ["file_blobs.id"], ondelete="RESTRICT"),
|
||||||
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||||
sa.ForeignKeyConstraint(["file_asset_id"], ["file_assets.id"], ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["file_asset_id"], ["file_assets.id"], ondelete="CASCADE"),
|
||||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.PrimaryKeyConstraint("id"),
|
||||||
sa.UniqueConstraint("file_asset_id", "version_number", name="uq_file_versions_asset_number"),
|
sa.UniqueConstraint("file_asset_id", "version_number", name="uq_file_versions_asset_number"),
|
||||||
)
|
)
|
||||||
@@ -101,9 +101,9 @@ def upgrade() -> None:
|
|||||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||||
sa.ForeignKeyConstraint(["file_asset_id"], ["file_assets.id"], ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["file_asset_id"], ["file_assets.id"], ondelete="CASCADE"),
|
||||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.PrimaryKeyConstraint("id"),
|
||||||
sa.UniqueConstraint("file_asset_id", "target_type", "target_id", "revoked_at", name="uq_file_shares_active_target"),
|
sa.UniqueConstraint("file_asset_id", "target_type", "target_id", "revoked_at", name="uq_file_shares_active_target"),
|
||||||
)
|
)
|
||||||
@@ -136,7 +136,7 @@ def upgrade() -> None:
|
|||||||
sa.ForeignKeyConstraint(["file_asset_id"], ["file_assets.id"], ondelete="RESTRICT"),
|
sa.ForeignKeyConstraint(["file_asset_id"], ["file_assets.id"], ondelete="RESTRICT"),
|
||||||
sa.ForeignKeyConstraint(["file_blob_id"], ["file_blobs.id"], ondelete="RESTRICT"),
|
sa.ForeignKeyConstraint(["file_blob_id"], ["file_blobs.id"], ondelete="RESTRICT"),
|
||||||
sa.ForeignKeyConstraint(["file_version_id"], ["file_versions.id"], ondelete="RESTRICT"),
|
sa.ForeignKeyConstraint(["file_version_id"], ["file_versions.id"], ondelete="RESTRICT"),
|
||||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.PrimaryKeyConstraint("id"),
|
||||||
sa.UniqueConstraint("campaign_job_id", "file_version_id", "filename_used", "use_stage", name="uq_campaign_attachment_uses_job_file_stage"),
|
sa.UniqueConstraint("campaign_job_id", "file_version_id", "filename_used", "use_stage", name="uq_campaign_attachment_uses_job_file_stage"),
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""add core change sequence
|
||||||
|
|
||||||
|
Revision ID: 3f4a5b6c7d8e
|
||||||
|
Revises: 2e3f4a5b6c7d
|
||||||
|
Create Date: 2026-07-09 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "3f4a5b6c7d8e"
|
||||||
|
down_revision = "2e3f4a5b6c7d"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "core_change_sequence" not in inspector.get_table_names():
|
||||||
|
op.create_table(
|
||||||
|
"core_change_sequence",
|
||||||
|
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("module_id", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("collection", sa.String(length=150), nullable=False),
|
||||||
|
sa.Column("resource_type", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("resource_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("operation", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("actor_type", sa.String(length=30), nullable=True),
|
||||||
|
sa.Column("actor_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("payload", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_change_sequence")),
|
||||||
|
)
|
||||||
|
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
indexes = {item["name"] for item in inspector.get_indexes("core_change_sequence")}
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"module_id",
|
||||||
|
"collection",
|
||||||
|
"resource_type",
|
||||||
|
"resource_id",
|
||||||
|
"operation",
|
||||||
|
"actor_type",
|
||||||
|
"actor_id",
|
||||||
|
"created_at",
|
||||||
|
):
|
||||||
|
name = op.f(f"ix_core_change_sequence_{column}")
|
||||||
|
if name not in indexes:
|
||||||
|
op.create_index(name, "core_change_sequence", [column], unique=False)
|
||||||
|
for name, columns in (
|
||||||
|
("ix_core_change_sequence_tenant_module_id", ["tenant_id", "module_id", "id"]),
|
||||||
|
("ix_core_change_sequence_collection_id", ["collection", "id"]),
|
||||||
|
("ix_core_change_sequence_resource", ["module_id", "resource_type", "resource_id"]),
|
||||||
|
):
|
||||||
|
if name not in indexes:
|
||||||
|
op.create_index(name, "core_change_sequence", columns, unique=False)
|
||||||
|
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "core_change_sequence_retention_floor" not in inspector.get_table_names():
|
||||||
|
op.create_table(
|
||||||
|
"core_change_sequence_retention_floor",
|
||||||
|
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tenant_key", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("module_id", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("collection", sa.String(length=150), nullable=False),
|
||||||
|
sa.Column("min_valid_sequence", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_change_sequence_retention_floor")),
|
||||||
|
sa.UniqueConstraint("tenant_key", "module_id", "collection", name="uq_core_change_sequence_retention_scope"),
|
||||||
|
)
|
||||||
|
indexes = {item["name"] for item in inspector.get_indexes("core_change_sequence_retention_floor")}
|
||||||
|
if "ix_core_change_sequence_retention_scope" not in indexes:
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_change_sequence_retention_scope",
|
||||||
|
"core_change_sequence_retention_floor",
|
||||||
|
["tenant_key", "module_id", "collection"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "core_change_sequence_retention_floor" in inspector.get_table_names():
|
||||||
|
indexes = {item["name"] for item in inspector.get_indexes("core_change_sequence_retention_floor")}
|
||||||
|
if "ix_core_change_sequence_retention_scope" in indexes:
|
||||||
|
op.drop_index("ix_core_change_sequence_retention_scope", table_name="core_change_sequence_retention_floor")
|
||||||
|
op.drop_table("core_change_sequence_retention_floor")
|
||||||
|
if "core_change_sequence" not in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
indexes = {item["name"] for item in inspector.get_indexes("core_change_sequence")}
|
||||||
|
for name in (
|
||||||
|
"ix_core_change_sequence_resource",
|
||||||
|
"ix_core_change_sequence_collection_id",
|
||||||
|
"ix_core_change_sequence_tenant_module_id",
|
||||||
|
op.f("ix_core_change_sequence_created_at"),
|
||||||
|
op.f("ix_core_change_sequence_actor_id"),
|
||||||
|
op.f("ix_core_change_sequence_actor_type"),
|
||||||
|
op.f("ix_core_change_sequence_operation"),
|
||||||
|
op.f("ix_core_change_sequence_resource_id"),
|
||||||
|
op.f("ix_core_change_sequence_resource_type"),
|
||||||
|
op.f("ix_core_change_sequence_collection"),
|
||||||
|
op.f("ix_core_change_sequence_module_id"),
|
||||||
|
op.f("ix_core_change_sequence_tenant_id"),
|
||||||
|
):
|
||||||
|
if name in indexes:
|
||||||
|
op.drop_index(name, table_name="core_change_sequence")
|
||||||
|
op.drop_table("core_change_sequence")
|
||||||
+4
-4
@@ -31,10 +31,10 @@ def upgrade() -> None:
|
|||||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||||
sa.ForeignKeyConstraint(["owner_group_id"], ["groups.id"], ondelete="SET NULL"),
|
sa.ForeignKeyConstraint(["owner_group_id"], ["access_groups.id"], ondelete="SET NULL"),
|
||||||
sa.ForeignKeyConstraint(["owner_user_id"], ["users.id"], ondelete="SET NULL"),
|
sa.ForeignKeyConstraint(["owner_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.PrimaryKeyConstraint("id"),
|
||||||
)
|
)
|
||||||
for col in ["tenant_id", "owner_type", "owner_user_id", "owner_group_id", "path", "created_by_user_id", "deleted_at"]:
|
for col in ["tenant_id", "owner_type", "owner_user_id", "owner_group_id", "path", "created_by_user_id", "deleted_at"]:
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""rename tenancy scope table to core scopes
|
||||||
|
|
||||||
|
Revision ID: 4f2a9c8e7b6d
|
||||||
|
Revises: 3f4a5b6c7d8e
|
||||||
|
Create Date: 2026-07-10 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "4f2a9c8e7b6d"
|
||||||
|
down_revision = "3f4a5b6c7d8e"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
LEGACY_SCOPE_TABLE = "tenancy_tenants"
|
||||||
|
CORE_SCOPE_TABLE = "core_scopes"
|
||||||
|
LEGACY_SLUG_INDEX = "ix_tenancy_tenants_slug"
|
||||||
|
CORE_SLUG_INDEX = "ix_core_scopes_slug"
|
||||||
|
_KNOWN_SCOPE_TABLES = {LEGACY_SCOPE_TABLE, CORE_SCOPE_TABLE}
|
||||||
|
|
||||||
|
|
||||||
|
def _row_count(bind: sa.Connection, table_name: str) -> int:
|
||||||
|
if table_name not in _KNOWN_SCOPE_TABLES:
|
||||||
|
raise RuntimeError(f"Unexpected scope table name: {table_name}")
|
||||||
|
quoted = bind.dialect.identifier_preparer.quote(table_name)
|
||||||
|
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one()) # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_tables(bind: sa.Connection) -> set[str]:
|
||||||
|
return set(sa.inspect(bind).get_table_names())
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_table_if_empty(bind: sa.Connection, table_name: str) -> bool:
|
||||||
|
if _row_count(bind, table_name) != 0:
|
||||||
|
return False
|
||||||
|
op.drop_table(table_name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_slug_index(bind: sa.Connection, table_name: str, index_name: str, old_index_name: str) -> None:
|
||||||
|
indexes = {index["name"] for index in sa.inspect(bind).get_indexes(table_name)}
|
||||||
|
if old_index_name in indexes:
|
||||||
|
op.drop_index(old_index_name, table_name=table_name)
|
||||||
|
indexes.remove(old_index_name)
|
||||||
|
if index_name not in indexes:
|
||||||
|
op.create_index(op.f(index_name), table_name, ["slug"], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _rename_scope_table(old_name: str, new_name: str) -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
tables = _scope_tables(bind)
|
||||||
|
if old_name not in tables:
|
||||||
|
if new_name in tables:
|
||||||
|
_ensure_slug_index(bind, new_name, CORE_SLUG_INDEX if new_name == CORE_SCOPE_TABLE else LEGACY_SLUG_INDEX, LEGACY_SLUG_INDEX if new_name == CORE_SCOPE_TABLE else CORE_SLUG_INDEX)
|
||||||
|
return
|
||||||
|
|
||||||
|
if new_name in tables:
|
||||||
|
if _drop_table_if_empty(bind, new_name):
|
||||||
|
tables.remove(new_name)
|
||||||
|
elif _drop_table_if_empty(bind, old_name):
|
||||||
|
_ensure_slug_index(bind, new_name, CORE_SLUG_INDEX if new_name == CORE_SCOPE_TABLE else LEGACY_SLUG_INDEX, LEGACY_SLUG_INDEX if new_name == CORE_SCOPE_TABLE else CORE_SLUG_INDEX)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"Cannot reconcile non-empty {old_name} over non-empty {new_name}")
|
||||||
|
|
||||||
|
if old_name in tables and new_name not in tables:
|
||||||
|
op.rename_table(old_name, new_name)
|
||||||
|
_ensure_slug_index(bind, new_name, CORE_SLUG_INDEX if new_name == CORE_SCOPE_TABLE else LEGACY_SLUG_INDEX, LEGACY_SLUG_INDEX if new_name == CORE_SCOPE_TABLE else CORE_SLUG_INDEX)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
_rename_scope_table(LEGACY_SCOPE_TABLE, CORE_SCOPE_TABLE)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
_rename_scope_table(CORE_SCOPE_TABLE, LEGACY_SCOPE_TABLE)
|
||||||
+1
-1
@@ -24,7 +24,7 @@ def upgrade() -> None:
|
|||||||
batch_op.add_column(sa.Column("user_locked_by_user_id", sa.String(length=36), nullable=True))
|
batch_op.add_column(sa.Column("user_locked_by_user_id", sa.String(length=36), nullable=True))
|
||||||
batch_op.create_foreign_key(
|
batch_op.create_foreign_key(
|
||||||
"fk_campaign_versions_user_locked_by_user_id_users",
|
"fk_campaign_versions_user_locked_by_user_id_users",
|
||||||
"users",
|
"access_users",
|
||||||
["user_locked_by_user_id"],
|
["user_locked_by_user_id"],
|
||||||
["id"],
|
["id"],
|
||||||
ondelete="SET NULL",
|
ondelete="SET NULL",
|
||||||
+50
-50
@@ -62,25 +62,25 @@ def upgrade() -> None:
|
|||||||
bind = op.get_bind()
|
bind = op.get_bind()
|
||||||
inspector = sa.inspect(bind)
|
inspector = sa.inspect(bind)
|
||||||
tables = set(inspector.get_table_names())
|
tables = set(inspector.get_table_names())
|
||||||
user_columns = {column["name"] for column in inspector.get_columns("users")}
|
user_columns = {column["name"] for column in inspector.get_columns("access_users")}
|
||||||
# Base.metadata.create_all() from a newer application can create brand-new
|
# Base.metadata.create_all() from a newer application can create brand-new
|
||||||
# tables while leaving existing tables unaltered. Repair that known drift by
|
# tables while leaving existing tables unaltered. Repair that known drift by
|
||||||
# removing only empty, unreferenced administration tables before applying the
|
# removing only empty, unreferenced administration tables before applying the
|
||||||
# real migration. A non-empty table is never guessed at or discarded.
|
# real migration. A non-empty table is never guessed at or discarded.
|
||||||
if "account_id" not in user_columns and "system_role_assignments" in tables:
|
if "account_id" not in user_columns and "access_system_role_assignments" in tables:
|
||||||
count = bind.execute(sa.text("SELECT COUNT(*) FROM system_role_assignments")).scalar_one()
|
count = bind.execute(sa.text("SELECT COUNT(*) FROM access_system_role_assignments")).scalar_one()
|
||||||
if count:
|
if count:
|
||||||
raise RuntimeError("Cannot reconcile non-empty create_all system_role_assignments table")
|
raise RuntimeError("Cannot reconcile non-empty create_all system_role_assignments table")
|
||||||
op.drop_table("system_role_assignments")
|
op.drop_table("access_system_role_assignments")
|
||||||
tables.remove("system_role_assignments")
|
tables.remove("access_system_role_assignments")
|
||||||
if "account_id" not in user_columns and "accounts" in tables:
|
if "account_id" not in user_columns and "access_accounts" in tables:
|
||||||
count = bind.execute(sa.text("SELECT COUNT(*) FROM accounts")).scalar_one()
|
count = bind.execute(sa.text("SELECT COUNT(*) FROM access_accounts")).scalar_one()
|
||||||
if count:
|
if count:
|
||||||
raise RuntimeError("Cannot reconcile non-empty create_all accounts table")
|
raise RuntimeError("Cannot reconcile non-empty create_all accounts table")
|
||||||
op.drop_table("accounts")
|
op.drop_table("access_accounts")
|
||||||
|
|
||||||
op.create_table(
|
op.create_table(
|
||||||
"accounts",
|
"access_accounts",
|
||||||
sa.Column("id", sa.String(length=36), nullable=False),
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
sa.Column("email", sa.String(length=320), nullable=False),
|
sa.Column("email", sa.String(length=320), nullable=False),
|
||||||
sa.Column("normalized_email", sa.String(length=320), nullable=False),
|
sa.Column("normalized_email", sa.String(length=320), nullable=False),
|
||||||
@@ -95,29 +95,29 @@ def upgrade() -> None:
|
|||||||
sa.PrimaryKeyConstraint("id"),
|
sa.PrimaryKeyConstraint("id"),
|
||||||
sa.UniqueConstraint("normalized_email", name="uq_accounts_normalized_email"),
|
sa.UniqueConstraint("normalized_email", name="uq_accounts_normalized_email"),
|
||||||
)
|
)
|
||||||
op.create_index(op.f("ix_accounts_normalized_email"), "accounts", ["normalized_email"])
|
op.create_index(op.f("ix_access_accounts_normalized_email"), "access_accounts", ["normalized_email"])
|
||||||
|
|
||||||
with op.batch_alter_table("tenants") as batch_op:
|
with op.batch_alter_table("tenancy_tenants") as batch_op:
|
||||||
batch_op.add_column(sa.Column("description", sa.Text(), nullable=True))
|
batch_op.add_column(sa.Column("description", sa.Text(), nullable=True))
|
||||||
batch_op.add_column(sa.Column("default_locale", sa.String(length=20), nullable=False, server_default="en"))
|
batch_op.add_column(sa.Column("default_locale", sa.String(length=20), nullable=False, server_default="en"))
|
||||||
batch_op.add_column(sa.Column("settings", sa.JSON(), nullable=False, server_default="{}"))
|
batch_op.add_column(sa.Column("settings", sa.JSON(), nullable=False, server_default="{}"))
|
||||||
|
|
||||||
with op.batch_alter_table("groups") as batch_op:
|
with op.batch_alter_table("access_groups") as batch_op:
|
||||||
batch_op.add_column(sa.Column("description", sa.Text(), nullable=True))
|
batch_op.add_column(sa.Column("description", sa.Text(), nullable=True))
|
||||||
batch_op.add_column(sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()))
|
batch_op.add_column(sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()))
|
||||||
|
|
||||||
with op.batch_alter_table("roles") as batch_op:
|
with op.batch_alter_table("access_roles") as batch_op:
|
||||||
batch_op.add_column(sa.Column("description", sa.Text(), nullable=True))
|
batch_op.add_column(sa.Column("description", sa.Text(), nullable=True))
|
||||||
batch_op.add_column(sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default=sa.false()))
|
batch_op.add_column(sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||||
batch_op.add_column(sa.Column("is_assignable", sa.Boolean(), nullable=False, server_default=sa.true()))
|
batch_op.add_column(sa.Column("is_assignable", sa.Boolean(), nullable=False, server_default=sa.true()))
|
||||||
|
|
||||||
with op.batch_alter_table("users") as batch_op:
|
with op.batch_alter_table("access_users") as batch_op:
|
||||||
batch_op.add_column(sa.Column("account_id", sa.String(length=36), nullable=True))
|
batch_op.add_column(sa.Column("account_id", sa.String(length=36), nullable=True))
|
||||||
with op.batch_alter_table("auth_sessions") as batch_op:
|
with op.batch_alter_table("access_auth_sessions") as batch_op:
|
||||||
batch_op.add_column(sa.Column("account_id", sa.String(length=36), nullable=True))
|
batch_op.add_column(sa.Column("account_id", sa.String(length=36), nullable=True))
|
||||||
|
|
||||||
users = bind.execute(sa.text(
|
users = bind.execute(sa.text(
|
||||||
"SELECT id, tenant_id, email, display_name, is_active, is_tenant_admin, auth_provider, password_hash, last_login_at, created_at, updated_at FROM users ORDER BY created_at, id"
|
"SELECT id, tenant_id, email, display_name, is_active, is_tenant_admin, auth_provider, password_hash, last_login_at, created_at, updated_at FROM access_users ORDER BY created_at, id"
|
||||||
)).mappings().all()
|
)).mappings().all()
|
||||||
|
|
||||||
accounts_by_email: dict[str, str] = {}
|
accounts_by_email: dict[str, str] = {}
|
||||||
@@ -155,7 +155,7 @@ def upgrade() -> None:
|
|||||||
first_system_owner_account_id = account_id
|
first_system_owner_account_id = account_id
|
||||||
|
|
||||||
accounts_table = sa.table(
|
accounts_table = sa.table(
|
||||||
"accounts",
|
"access_accounts",
|
||||||
sa.column("id", sa.String), sa.column("email", sa.String), sa.column("normalized_email", sa.String),
|
sa.column("id", sa.String), sa.column("email", sa.String), sa.column("normalized_email", sa.String),
|
||||||
sa.column("display_name", sa.String), sa.column("is_active", sa.Boolean), sa.column("auth_provider", sa.String),
|
sa.column("display_name", sa.String), sa.column("is_active", sa.Boolean), sa.column("auth_provider", sa.String),
|
||||||
sa.column("password_hash", sa.String), sa.column("password_reset_required", sa.Boolean),
|
sa.column("password_hash", sa.String), sa.column("password_reset_required", sa.Boolean),
|
||||||
@@ -166,54 +166,54 @@ def upgrade() -> None:
|
|||||||
bind.execute(accounts_table.insert(), list(account_rows.values()))
|
bind.execute(accounts_table.insert(), list(account_rows.values()))
|
||||||
for row in users:
|
for row in users:
|
||||||
bind.execute(
|
bind.execute(
|
||||||
sa.text("UPDATE users SET account_id = :account_id WHERE id = :user_id"),
|
sa.text("UPDATE access_users SET account_id = :account_id WHERE id = :user_id"),
|
||||||
{"account_id": accounts_by_email[_normalize_email(row["email"])], "user_id": row["id"]},
|
{"account_id": accounts_by_email[_normalize_email(row["email"])], "user_id": row["id"]},
|
||||||
)
|
)
|
||||||
bind.execute(sa.text(
|
bind.execute(sa.text(
|
||||||
"UPDATE auth_sessions SET account_id = (SELECT users.account_id FROM users WHERE users.id = auth_sessions.user_id)"
|
"UPDATE access_auth_sessions SET account_id = (SELECT access_users.account_id FROM access_users WHERE access_users.id = access_auth_sessions.user_id)"
|
||||||
))
|
))
|
||||||
|
|
||||||
with op.batch_alter_table("users") as batch_op:
|
with op.batch_alter_table("access_users") as batch_op:
|
||||||
batch_op.alter_column("account_id", existing_type=sa.String(length=36), nullable=False)
|
batch_op.alter_column("account_id", existing_type=sa.String(length=36), nullable=False)
|
||||||
batch_op.create_foreign_key("fk_users_account_id_accounts", "accounts", ["account_id"], ["id"], ondelete="CASCADE")
|
batch_op.create_foreign_key("fk_users_account_id_accounts", "access_accounts", ["account_id"], ["id"], ondelete="CASCADE")
|
||||||
batch_op.create_unique_constraint("uq_users_tenant_account", ["tenant_id", "account_id"])
|
batch_op.create_unique_constraint("uq_users_tenant_account", ["tenant_id", "account_id"])
|
||||||
op.create_index(op.f("ix_users_account_id"), "users", ["account_id"])
|
op.create_index(op.f("ix_access_users_account_id"), "access_users", ["account_id"])
|
||||||
|
|
||||||
with op.batch_alter_table("auth_sessions") as batch_op:
|
with op.batch_alter_table("access_auth_sessions") as batch_op:
|
||||||
batch_op.alter_column("account_id", existing_type=sa.String(length=36), nullable=False)
|
batch_op.alter_column("account_id", existing_type=sa.String(length=36), nullable=False)
|
||||||
batch_op.create_foreign_key("fk_auth_sessions_account_id_accounts", "accounts", ["account_id"], ["id"], ondelete="CASCADE")
|
batch_op.create_foreign_key("fk_auth_sessions_account_id_accounts", "access_accounts", ["account_id"], ["id"], ondelete="CASCADE")
|
||||||
op.create_index(op.f("ix_auth_sessions_account_id"), "auth_sessions", ["account_id"])
|
op.create_index(op.f("ix_access_auth_sessions_account_id"), "access_auth_sessions", ["account_id"])
|
||||||
|
|
||||||
op.create_table(
|
op.create_table(
|
||||||
"system_role_assignments",
|
"access_system_role_assignments",
|
||||||
sa.Column("id", sa.String(length=36), nullable=False),
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
sa.Column("account_id", sa.String(length=36), nullable=False),
|
sa.Column("account_id", sa.String(length=36), nullable=False),
|
||||||
sa.Column("role_id", sa.String(length=36), nullable=False),
|
sa.Column("role_id", sa.String(length=36), nullable=False),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["account_id"], ["access_accounts.id"], ondelete="CASCADE"),
|
||||||
sa.ForeignKeyConstraint(["role_id"], ["roles.id"], ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["role_id"], ["access_roles.id"], ondelete="CASCADE"),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.PrimaryKeyConstraint("id"),
|
||||||
sa.UniqueConstraint("account_id", "role_id", name="uq_system_role_assignments"),
|
sa.UniqueConstraint("account_id", "role_id", name="uq_system_role_assignments"),
|
||||||
)
|
)
|
||||||
op.create_index(op.f("ix_system_role_assignments_account_id"), "system_role_assignments", ["account_id"])
|
op.create_index(op.f("ix_access_system_role_assignments_account_id"), "access_system_role_assignments", ["account_id"])
|
||||||
op.create_index(op.f("ix_system_role_assignments_role_id"), "system_role_assignments", ["role_id"])
|
op.create_index(op.f("ix_access_system_role_assignments_role_id"), "access_system_role_assignments", ["role_id"])
|
||||||
|
|
||||||
roles_table = sa.table(
|
roles_table = sa.table(
|
||||||
"roles",
|
"access_roles",
|
||||||
sa.column("id", sa.String), sa.column("tenant_id", sa.String), sa.column("slug", sa.String),
|
sa.column("id", sa.String), sa.column("tenant_id", sa.String), sa.column("slug", sa.String),
|
||||||
sa.column("name", sa.String), sa.column("description", sa.Text), sa.column("permissions", sa.JSON),
|
sa.column("name", sa.String), sa.column("description", sa.Text), sa.column("permissions", sa.JSON),
|
||||||
sa.column("is_builtin", sa.Boolean), sa.column("is_assignable", sa.Boolean),
|
sa.column("is_builtin", sa.Boolean), sa.column("is_assignable", sa.Boolean),
|
||||||
sa.column("created_at", sa.DateTime(timezone=True)), sa.column("updated_at", sa.DateTime(timezone=True)),
|
sa.column("created_at", sa.DateTime(timezone=True)), sa.column("updated_at", sa.DateTime(timezone=True)),
|
||||||
)
|
)
|
||||||
now = _now()
|
now = _now()
|
||||||
tenant_ids = [row[0] for row in bind.execute(sa.text("SELECT id FROM tenants")).all()]
|
tenant_ids = [row[0] for row in bind.execute(sa.text("SELECT id FROM tenancy_tenants")).all()]
|
||||||
definitions = _role_definitions()
|
definitions = _role_definitions()
|
||||||
tenant_role_ids: dict[tuple[str, str], str] = {}
|
tenant_role_ids: dict[tuple[str, str], str] = {}
|
||||||
for tenant_id in tenant_ids:
|
for tenant_id in tenant_ids:
|
||||||
for slug, definition in definitions.items():
|
for slug, definition in definitions.items():
|
||||||
existing = bind.execute(
|
existing = bind.execute(
|
||||||
sa.text("SELECT id FROM roles WHERE tenant_id = :tenant_id AND slug = :slug"),
|
sa.text("SELECT id FROM access_roles WHERE tenant_id = :tenant_id AND slug = :slug"),
|
||||||
{"tenant_id": tenant_id, "slug": slug},
|
{"tenant_id": tenant_id, "slug": slug},
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if existing:
|
if existing:
|
||||||
@@ -253,7 +253,7 @@ def upgrade() -> None:
|
|||||||
|
|
||||||
op.create_index(
|
op.create_index(
|
||||||
"uq_roles_system_slug",
|
"uq_roles_system_slug",
|
||||||
"roles",
|
"access_roles",
|
||||||
["slug"],
|
["slug"],
|
||||||
unique=True,
|
unique=True,
|
||||||
sqlite_where=sa.text("tenant_id IS NULL"),
|
sqlite_where=sa.text("tenant_id IS NULL"),
|
||||||
@@ -267,11 +267,11 @@ def upgrade() -> None:
|
|||||||
continue
|
continue
|
||||||
owner_role_id = tenant_role_ids[(row["tenant_id"], "owner")]
|
owner_role_id = tenant_role_ids[(row["tenant_id"], "owner")]
|
||||||
exists = bind.execute(sa.text(
|
exists = bind.execute(sa.text(
|
||||||
"SELECT 1 FROM user_role_assignments WHERE tenant_id = :tenant_id AND user_id = :user_id AND role_id = :role_id"
|
"SELECT 1 FROM access_user_role_assignments WHERE tenant_id = :tenant_id AND user_id = :user_id AND role_id = :role_id"
|
||||||
), {"tenant_id": row["tenant_id"], "user_id": row["id"], "role_id": owner_role_id}).first()
|
), {"tenant_id": row["tenant_id"], "user_id": row["id"], "role_id": owner_role_id}).first()
|
||||||
if not exists:
|
if not exists:
|
||||||
bind.execute(sa.text(
|
bind.execute(sa.text(
|
||||||
"INSERT INTO user_role_assignments (id, tenant_id, user_id, role_id, created_at, updated_at) VALUES (:id, :tenant_id, :user_id, :role_id, :created_at, :updated_at)"
|
"INSERT INTO access_user_role_assignments (id, tenant_id, user_id, role_id, created_at, updated_at) VALUES (:id, :tenant_id, :user_id, :role_id, :created_at, :updated_at)"
|
||||||
), {"id": str(uuid.uuid4()), "tenant_id": row["tenant_id"], "user_id": row["id"], "role_id": owner_role_id, "created_at": now, "updated_at": now})
|
), {"id": str(uuid.uuid4()), "tenant_id": row["tenant_id"], "user_id": row["id"], "role_id": owner_role_id, "created_at": now, "updated_at": now})
|
||||||
|
|
||||||
# Bootstrap rule for existing installations: the earliest active legacy
|
# Bootstrap rule for existing installations: the earliest active legacy
|
||||||
@@ -284,40 +284,40 @@ def upgrade() -> None:
|
|||||||
), None)
|
), None)
|
||||||
if first_system_owner_account_id:
|
if first_system_owner_account_id:
|
||||||
bind.execute(sa.text(
|
bind.execute(sa.text(
|
||||||
"INSERT INTO system_role_assignments (id, account_id, role_id, created_at, updated_at) VALUES (:id, :account_id, :role_id, :created_at, :updated_at)"
|
"INSERT INTO access_system_role_assignments (id, account_id, role_id, created_at, updated_at) VALUES (:id, :account_id, :role_id, :created_at, :updated_at)"
|
||||||
), {"id": str(uuid.uuid4()), "account_id": first_system_owner_account_id, "role_id": system_owner_role_id, "created_at": now, "updated_at": now})
|
), {"id": str(uuid.uuid4()), "account_id": first_system_owner_account_id, "role_id": system_owner_role_id, "created_at": now, "updated_at": now})
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
op.drop_index("uq_roles_system_slug", table_name="roles")
|
op.drop_index("uq_roles_system_slug", table_name="access_roles")
|
||||||
op.drop_index(op.f("ix_system_role_assignments_role_id"), table_name="system_role_assignments")
|
op.drop_index(op.f("ix_access_system_role_assignments_role_id"), table_name="access_system_role_assignments")
|
||||||
op.drop_index(op.f("ix_system_role_assignments_account_id"), table_name="system_role_assignments")
|
op.drop_index(op.f("ix_access_system_role_assignments_account_id"), table_name="access_system_role_assignments")
|
||||||
op.drop_table("system_role_assignments")
|
op.drop_table("access_system_role_assignments")
|
||||||
|
|
||||||
op.drop_index(op.f("ix_auth_sessions_account_id"), table_name="auth_sessions")
|
op.drop_index(op.f("ix_access_auth_sessions_account_id"), table_name="access_auth_sessions")
|
||||||
with op.batch_alter_table("auth_sessions") as batch_op:
|
with op.batch_alter_table("access_auth_sessions") as batch_op:
|
||||||
batch_op.drop_constraint("fk_auth_sessions_account_id_accounts", type_="foreignkey")
|
batch_op.drop_constraint("fk_auth_sessions_account_id_accounts", type_="foreignkey")
|
||||||
batch_op.drop_column("account_id")
|
batch_op.drop_column("account_id")
|
||||||
|
|
||||||
op.drop_index(op.f("ix_users_account_id"), table_name="users")
|
op.drop_index(op.f("ix_access_users_account_id"), table_name="access_users")
|
||||||
with op.batch_alter_table("users") as batch_op:
|
with op.batch_alter_table("access_users") as batch_op:
|
||||||
batch_op.drop_constraint("uq_users_tenant_account", type_="unique")
|
batch_op.drop_constraint("uq_users_tenant_account", type_="unique")
|
||||||
batch_op.drop_constraint("fk_users_account_id_accounts", type_="foreignkey")
|
batch_op.drop_constraint("fk_users_account_id_accounts", type_="foreignkey")
|
||||||
batch_op.drop_column("account_id")
|
batch_op.drop_column("account_id")
|
||||||
|
|
||||||
with op.batch_alter_table("roles") as batch_op:
|
with op.batch_alter_table("access_roles") as batch_op:
|
||||||
batch_op.drop_column("is_assignable")
|
batch_op.drop_column("is_assignable")
|
||||||
batch_op.drop_column("is_builtin")
|
batch_op.drop_column("is_builtin")
|
||||||
batch_op.drop_column("description")
|
batch_op.drop_column("description")
|
||||||
|
|
||||||
with op.batch_alter_table("groups") as batch_op:
|
with op.batch_alter_table("access_groups") as batch_op:
|
||||||
batch_op.drop_column("is_active")
|
batch_op.drop_column("is_active")
|
||||||
batch_op.drop_column("description")
|
batch_op.drop_column("description")
|
||||||
|
|
||||||
with op.batch_alter_table("tenants") as batch_op:
|
with op.batch_alter_table("tenancy_tenants") as batch_op:
|
||||||
batch_op.drop_column("settings")
|
batch_op.drop_column("settings")
|
||||||
batch_op.drop_column("default_locale")
|
batch_op.drop_column("default_locale")
|
||||||
batch_op.drop_column("description")
|
batch_op.drop_column("description")
|
||||||
|
|
||||||
op.drop_index(op.f("ix_accounts_normalized_email"), table_name="accounts")
|
op.drop_index(op.f("ix_access_accounts_normalized_email"), table_name="access_accounts")
|
||||||
op.drop_table("accounts")
|
op.drop_table("access_accounts")
|
||||||
+42
-33
@@ -16,6 +16,7 @@ revision = "9d0e1f2a3b4c"
|
|||||||
down_revision = "8c9d0e1f2a3b"
|
down_revision = "8c9d0e1f2a3b"
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
_RECONCILE_CREATE_ALL_TABLES = ("admin_governance_template_assignments", "admin_governance_templates", "core_system_settings")
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
@@ -28,15 +29,16 @@ def upgrade() -> None:
|
|||||||
tables = set(inspector.get_table_names())
|
tables = set(inspector.get_table_names())
|
||||||
|
|
||||||
# Reconcile only the empty create_all shape for the newly introduced tables.
|
# Reconcile only the empty create_all shape for the newly introduced tables.
|
||||||
for table_name in ("governance_template_assignments", "governance_templates", "system_settings"):
|
for table_name in _RECONCILE_CREATE_ALL_TABLES:
|
||||||
if table_name in tables:
|
if table_name in tables:
|
||||||
count = bind.execute(sa.text(f"SELECT COUNT(*) FROM {table_name}")).scalar_one()
|
quoted = bind.dialect.identifier_preparer.quote(table_name)
|
||||||
|
count = bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one() # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||||
if count:
|
if count:
|
||||||
raise RuntimeError(f"Cannot reconcile non-empty create_all table {table_name}")
|
raise RuntimeError(f"Cannot reconcile non-empty create_all table {table_name}")
|
||||||
op.drop_table(table_name)
|
op.drop_table(table_name)
|
||||||
|
|
||||||
op.create_table(
|
op.create_table(
|
||||||
"system_settings",
|
"core_system_settings",
|
||||||
sa.Column("id", sa.String(length=36), nullable=False),
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
sa.Column("default_locale", sa.String(length=20), nullable=False, server_default="en"),
|
sa.Column("default_locale", sa.String(length=20), nullable=False, server_default="en"),
|
||||||
sa.Column("allow_tenant_custom_groups", sa.Boolean(), nullable=False, server_default=sa.true()),
|
sa.Column("allow_tenant_custom_groups", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
@@ -50,16 +52,23 @@ def upgrade() -> None:
|
|||||||
now = _now()
|
now = _now()
|
||||||
bind.execute(sa.text(
|
bind.execute(sa.text(
|
||||||
"""
|
"""
|
||||||
INSERT INTO system_settings
|
INSERT INTO core_system_settings
|
||||||
(id, default_locale, allow_tenant_custom_groups, allow_tenant_custom_roles,
|
(id, default_locale, allow_tenant_custom_groups, allow_tenant_custom_roles,
|
||||||
allow_tenant_api_keys, settings, created_at, updated_at)
|
allow_tenant_api_keys, settings, created_at, updated_at)
|
||||||
VALUES
|
VALUES
|
||||||
('global', 'en', 1, 1, 1, '{}', :created_at, :updated_at)
|
('global', 'en', :allow_tenant_custom_groups, :allow_tenant_custom_roles,
|
||||||
|
:allow_tenant_api_keys, '{}', :created_at, :updated_at)
|
||||||
"""
|
"""
|
||||||
), {"created_at": now, "updated_at": now})
|
), {
|
||||||
|
"allow_tenant_custom_groups": True,
|
||||||
|
"allow_tenant_custom_roles": True,
|
||||||
|
"allow_tenant_api_keys": True,
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
})
|
||||||
|
|
||||||
op.create_table(
|
op.create_table(
|
||||||
"governance_templates",
|
"admin_governance_templates",
|
||||||
sa.Column("id", sa.String(length=36), nullable=False),
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
sa.Column("kind", sa.String(length=20), nullable=False),
|
sa.Column("kind", sa.String(length=20), nullable=False),
|
||||||
sa.Column("slug", sa.String(length=100), nullable=False),
|
sa.Column("slug", sa.String(length=100), nullable=False),
|
||||||
@@ -72,51 +81,51 @@ def upgrade() -> None:
|
|||||||
sa.PrimaryKeyConstraint("id"),
|
sa.PrimaryKeyConstraint("id"),
|
||||||
sa.UniqueConstraint("kind", "slug", name="uq_governance_templates_kind_slug"),
|
sa.UniqueConstraint("kind", "slug", name="uq_governance_templates_kind_slug"),
|
||||||
)
|
)
|
||||||
op.create_index(op.f("ix_governance_templates_kind"), "governance_templates", ["kind"])
|
op.create_index(op.f("ix_admin_governance_templates_kind"), "admin_governance_templates", ["kind"])
|
||||||
|
|
||||||
op.create_table(
|
op.create_table(
|
||||||
"governance_template_assignments",
|
"admin_governance_template_assignments",
|
||||||
sa.Column("id", sa.String(length=36), nullable=False),
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
sa.Column("template_id", sa.String(length=36), nullable=False),
|
sa.Column("template_id", sa.String(length=36), nullable=False),
|
||||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
sa.Column("mode", sa.String(length=20), nullable=False, server_default="available"),
|
sa.Column("mode", sa.String(length=20), nullable=False, server_default="available"),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(["template_id"], ["governance_templates.id"], ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["template_id"], ["admin_governance_templates.id"], ondelete="CASCADE"),
|
||||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.PrimaryKeyConstraint("id"),
|
||||||
sa.UniqueConstraint("template_id", "tenant_id", name="uq_governance_template_tenant"),
|
sa.UniqueConstraint("template_id", "tenant_id", name="uq_governance_template_tenant"),
|
||||||
)
|
)
|
||||||
op.create_index(op.f("ix_governance_template_assignments_template_id"), "governance_template_assignments", ["template_id"])
|
op.create_index(op.f("ix_admin_governance_template_assignments_template_id"), "admin_governance_template_assignments", ["template_id"])
|
||||||
op.create_index(op.f("ix_governance_template_assignments_tenant_id"), "governance_template_assignments", ["tenant_id"])
|
op.create_index(op.f("ix_admin_governance_template_assignments_tenant_id"), "admin_governance_template_assignments", ["tenant_id"])
|
||||||
|
|
||||||
with op.batch_alter_table("tenants") as batch_op:
|
with op.batch_alter_table("tenancy_tenants") as batch_op:
|
||||||
batch_op.add_column(sa.Column("allow_custom_groups", sa.Boolean(), nullable=True))
|
batch_op.add_column(sa.Column("allow_custom_groups", sa.Boolean(), nullable=True))
|
||||||
batch_op.add_column(sa.Column("allow_custom_roles", sa.Boolean(), nullable=True))
|
batch_op.add_column(sa.Column("allow_custom_roles", sa.Boolean(), nullable=True))
|
||||||
batch_op.add_column(sa.Column("allow_api_keys", sa.Boolean(), nullable=True))
|
batch_op.add_column(sa.Column("allow_api_keys", sa.Boolean(), nullable=True))
|
||||||
|
|
||||||
with op.batch_alter_table("groups") as batch_op:
|
with op.batch_alter_table("access_groups") as batch_op:
|
||||||
batch_op.add_column(sa.Column("system_template_id", sa.String(length=36), nullable=True))
|
batch_op.add_column(sa.Column("system_template_id", sa.String(length=36), nullable=True))
|
||||||
batch_op.add_column(sa.Column("system_required", sa.Boolean(), nullable=False, server_default=sa.false()))
|
batch_op.add_column(sa.Column("system_required", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||||
batch_op.create_foreign_key(
|
batch_op.create_foreign_key(
|
||||||
"fk_groups_system_template_id_governance_templates",
|
"fk_groups_system_template_id_governance_templates",
|
||||||
"governance_templates", ["system_template_id"], ["id"], ondelete="SET NULL",
|
"admin_governance_templates", ["system_template_id"], ["id"], ondelete="SET NULL",
|
||||||
)
|
)
|
||||||
op.create_index(op.f("ix_groups_system_template_id"), "groups", ["system_template_id"])
|
op.create_index(op.f("ix_access_groups_system_template_id"), "access_groups", ["system_template_id"])
|
||||||
|
|
||||||
with op.batch_alter_table("roles") as batch_op:
|
with op.batch_alter_table("access_roles") as batch_op:
|
||||||
batch_op.add_column(sa.Column("system_template_id", sa.String(length=36), nullable=True))
|
batch_op.add_column(sa.Column("system_template_id", sa.String(length=36), nullable=True))
|
||||||
batch_op.add_column(sa.Column("system_required", sa.Boolean(), nullable=False, server_default=sa.false()))
|
batch_op.add_column(sa.Column("system_required", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||||
batch_op.create_foreign_key(
|
batch_op.create_foreign_key(
|
||||||
"fk_roles_system_template_id_governance_templates",
|
"fk_roles_system_template_id_governance_templates",
|
||||||
"governance_templates", ["system_template_id"], ["id"], ondelete="SET NULL",
|
"admin_governance_templates", ["system_template_id"], ["id"], ondelete="SET NULL",
|
||||||
)
|
)
|
||||||
op.create_index(op.f("ix_roles_system_template_id"), "roles", ["system_template_id"])
|
op.create_index(op.f("ix_access_roles_system_template_id"), "access_roles", ["system_template_id"])
|
||||||
|
|
||||||
# Existing system owners use system:* and need no data change. Extend the
|
# Existing system owners use system:* and need no data change. Extend the
|
||||||
# read-only built-in auditor role to the newly introduced read scopes.
|
# read-only built-in auditor role to the newly introduced read scopes.
|
||||||
auditor = bind.execute(sa.text(
|
auditor = bind.execute(sa.text(
|
||||||
"SELECT id, permissions FROM roles WHERE tenant_id IS NULL AND slug = 'system_auditor'"
|
"SELECT id, permissions FROM access_roles WHERE tenant_id IS NULL AND slug = 'system_auditor'"
|
||||||
)).mappings().first()
|
)).mappings().first()
|
||||||
if auditor:
|
if auditor:
|
||||||
raw_permissions = auditor["permissions"] or []
|
raw_permissions = auditor["permissions"] or []
|
||||||
@@ -125,7 +134,7 @@ def upgrade() -> None:
|
|||||||
if scope not in permissions:
|
if scope not in permissions:
|
||||||
permissions.append(scope)
|
permissions.append(scope)
|
||||||
roles_table = sa.table(
|
roles_table = sa.table(
|
||||||
"roles",
|
"access_roles",
|
||||||
sa.column("id", sa.String),
|
sa.column("id", sa.String),
|
||||||
sa.column("permissions", sa.JSON),
|
sa.column("permissions", sa.JSON),
|
||||||
sa.column("updated_at", sa.DateTime(timezone=True)),
|
sa.column("updated_at", sa.DateTime(timezone=True)),
|
||||||
@@ -138,26 +147,26 @@ def upgrade() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
op.drop_index(op.f("ix_roles_system_template_id"), table_name="roles")
|
op.drop_index(op.f("ix_access_roles_system_template_id"), table_name="access_roles")
|
||||||
with op.batch_alter_table("roles") as batch_op:
|
with op.batch_alter_table("access_roles") as batch_op:
|
||||||
batch_op.drop_constraint("fk_roles_system_template_id_governance_templates", type_="foreignkey")
|
batch_op.drop_constraint("fk_roles_system_template_id_governance_templates", type_="foreignkey")
|
||||||
batch_op.drop_column("system_required")
|
batch_op.drop_column("system_required")
|
||||||
batch_op.drop_column("system_template_id")
|
batch_op.drop_column("system_template_id")
|
||||||
|
|
||||||
op.drop_index(op.f("ix_groups_system_template_id"), table_name="groups")
|
op.drop_index(op.f("ix_access_groups_system_template_id"), table_name="access_groups")
|
||||||
with op.batch_alter_table("groups") as batch_op:
|
with op.batch_alter_table("access_groups") as batch_op:
|
||||||
batch_op.drop_constraint("fk_groups_system_template_id_governance_templates", type_="foreignkey")
|
batch_op.drop_constraint("fk_groups_system_template_id_governance_templates", type_="foreignkey")
|
||||||
batch_op.drop_column("system_required")
|
batch_op.drop_column("system_required")
|
||||||
batch_op.drop_column("system_template_id")
|
batch_op.drop_column("system_template_id")
|
||||||
|
|
||||||
with op.batch_alter_table("tenants") as batch_op:
|
with op.batch_alter_table("tenancy_tenants") as batch_op:
|
||||||
batch_op.drop_column("allow_api_keys")
|
batch_op.drop_column("allow_api_keys")
|
||||||
batch_op.drop_column("allow_custom_roles")
|
batch_op.drop_column("allow_custom_roles")
|
||||||
batch_op.drop_column("allow_custom_groups")
|
batch_op.drop_column("allow_custom_groups")
|
||||||
|
|
||||||
op.drop_index(op.f("ix_governance_template_assignments_tenant_id"), table_name="governance_template_assignments")
|
op.drop_index(op.f("ix_admin_governance_template_assignments_tenant_id"), table_name="admin_governance_template_assignments")
|
||||||
op.drop_index(op.f("ix_governance_template_assignments_template_id"), table_name="governance_template_assignments")
|
op.drop_index(op.f("ix_admin_governance_template_assignments_template_id"), table_name="admin_governance_template_assignments")
|
||||||
op.drop_table("governance_template_assignments")
|
op.drop_table("admin_governance_template_assignments")
|
||||||
op.drop_index(op.f("ix_governance_templates_kind"), table_name="governance_templates")
|
op.drop_index(op.f("ix_admin_governance_templates_kind"), table_name="admin_governance_templates")
|
||||||
op.drop_table("governance_templates")
|
op.drop_table("admin_governance_templates")
|
||||||
op.drop_table("system_settings")
|
op.drop_table("core_system_settings")
|
||||||
+9
-9
@@ -148,9 +148,9 @@ def upgrade() -> None:
|
|||||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
|
||||||
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
|
||||||
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.PrimaryKeyConstraint("id"),
|
||||||
sa.UniqueConstraint("campaign_id", "target_type", "target_id", name="uq_campaign_share_target"),
|
sa.UniqueConstraint("campaign_id", "target_type", "target_id", name="uq_campaign_share_target"),
|
||||||
)
|
)
|
||||||
@@ -160,32 +160,32 @@ def upgrade() -> None:
|
|||||||
op.create_index("ix_campaign_shares_target_id", "campaign_shares", ["target_id"])
|
op.create_index("ix_campaign_shares_target_id", "campaign_shares", ["target_id"])
|
||||||
op.create_index("ix_campaign_shares_created_by_user_id", "campaign_shares", ["created_by_user_id"])
|
op.create_index("ix_campaign_shares_created_by_user_id", "campaign_shares", ["created_by_user_id"])
|
||||||
op.create_index("ix_campaign_shares_revoked_at", "campaign_shares", ["revoked_at"])
|
op.create_index("ix_campaign_shares_revoked_at", "campaign_shares", ["revoked_at"])
|
||||||
if "roles" not in tables:
|
if "access_roles" not in tables:
|
||||||
return
|
return
|
||||||
|
|
||||||
rows = bind.execute(sa.text("SELECT id, permissions FROM roles")).mappings().all()
|
rows = bind.execute(sa.text("SELECT id, permissions FROM access_roles")).mappings().all()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
bind.execute(
|
bind.execute(
|
||||||
sa.text("UPDATE roles SET permissions = :permissions WHERE id = :id"),
|
sa.text("UPDATE access_roles SET permissions = :permissions WHERE id = :id"),
|
||||||
{"id": row["id"], "permissions": _json(_expand_legacy(_decode(row["permissions"])))},
|
{"id": row["id"], "permissions": _json(_expand_legacy(_decode(row["permissions"])))},
|
||||||
)
|
)
|
||||||
|
|
||||||
for slug, permissions in TENANT_ROLE_PERMISSIONS.items():
|
for slug, permissions in TENANT_ROLE_PERMISSIONS.items():
|
||||||
bind.execute(
|
bind.execute(
|
||||||
sa.text("UPDATE roles SET permissions = :permissions WHERE tenant_id IS NOT NULL AND slug = :slug AND is_builtin = TRUE"),
|
sa.text("UPDATE access_roles SET permissions = :permissions WHERE tenant_id IS NOT NULL AND slug = :slug AND is_builtin = TRUE"),
|
||||||
{"slug": slug, "permissions": _json(permissions)},
|
{"slug": slug, "permissions": _json(permissions)},
|
||||||
)
|
)
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
for slug, permissions in SYSTEM_ROLE_PERMISSIONS.items():
|
for slug, permissions in SYSTEM_ROLE_PERMISSIONS.items():
|
||||||
existing = bind.execute(
|
existing = bind.execute(
|
||||||
sa.text("SELECT id FROM roles WHERE tenant_id IS NULL AND slug = :slug"), {"slug": slug}
|
sa.text("SELECT id FROM access_roles WHERE tenant_id IS NULL AND slug = :slug"), {"slug": slug}
|
||||||
).first()
|
).first()
|
||||||
is_protected = slug == "system_owner"
|
is_protected = slug == "system_owner"
|
||||||
if existing:
|
if existing:
|
||||||
bind.execute(
|
bind.execute(
|
||||||
sa.text(
|
sa.text(
|
||||||
"UPDATE roles SET permissions = :permissions, is_builtin = :is_builtin, is_assignable = TRUE "
|
"UPDATE access_roles SET permissions = :permissions, is_builtin = :is_builtin, is_assignable = TRUE "
|
||||||
"WHERE tenant_id IS NULL AND slug = :slug"
|
"WHERE tenant_id IS NULL AND slug = :slug"
|
||||||
),
|
),
|
||||||
{"slug": slug, "permissions": _json(permissions), "is_builtin": is_protected},
|
{"slug": slug, "permissions": _json(permissions), "is_builtin": is_protected},
|
||||||
@@ -199,7 +199,7 @@ def upgrade() -> None:
|
|||||||
name, description = names[slug]
|
name, description = names[slug]
|
||||||
bind.execute(
|
bind.execute(
|
||||||
sa.text(
|
sa.text(
|
||||||
"INSERT INTO roles (id, tenant_id, slug, name, description, permissions, is_builtin, is_assignable, "
|
"INSERT INTO access_roles (id, tenant_id, slug, name, description, permissions, is_builtin, is_assignable, "
|
||||||
"system_template_id, system_required, created_at, updated_at) "
|
"system_template_id, system_required, created_at, updated_at) "
|
||||||
"VALUES (:id, NULL, :slug, :name, :description, :permissions, :is_builtin, TRUE, NULL, FALSE, :created_at, :updated_at)"
|
"VALUES (:id, NULL, :slug, :name, :description, :permissions, :is_builtin, TRUE, NULL, FALSE, :created_at, :updated_at)"
|
||||||
),
|
),
|
||||||
@@ -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
|
||||||
+1
-1
@@ -49,7 +49,7 @@ def upgrade() -> None:
|
|||||||
placeholders = ", ".join(f":action_{index}" for index, _ in enumerate(SYSTEM_ACTIONS))
|
placeholders = ", ".join(f":action_{index}" for index, _ in enumerate(SYSTEM_ACTIONS))
|
||||||
params = {f"action_{index}": action for index, action in enumerate(SYSTEM_ACTIONS)}
|
params = {f"action_{index}": action for index, action in enumerate(SYSTEM_ACTIONS)}
|
||||||
bind.execute(
|
bind.execute(
|
||||||
sa.text(f"UPDATE audit_log SET scope = 'system' WHERE action IN ({placeholders})"),
|
sa.text(f"UPDATE audit_log SET scope = 'system' WHERE action IN ({placeholders})"), # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||||
params,
|
params,
|
||||||
)
|
)
|
||||||
bind.execute(sa.text("UPDATE audit_log SET scope = 'tenant' WHERE scope IS NULL OR scope NOT IN ('tenant', 'system')"))
|
bind.execute(sa.text("UPDATE audit_log SET scope = 'tenant' WHERE scope IS NULL OR scope NOT IN ('tenant', 'system')"))
|
||||||
@@ -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
|
||||||
+41
-41
@@ -18,7 +18,7 @@ depends_on = None
|
|||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.create_table('tenants',
|
op.create_table('tenancy_tenants',
|
||||||
sa.Column('id', sa.String(length=36), nullable=False),
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
sa.Column('slug', sa.String(length=100), nullable=False),
|
sa.Column('slug', sa.String(length=100), nullable=False),
|
||||||
sa.Column('name', sa.String(length=255), nullable=False),
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
@@ -27,7 +27,7 @@ def upgrade() -> None:
|
|||||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_tenants'))
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_tenants'))
|
||||||
)
|
)
|
||||||
op.create_index(op.f('ix_tenants_slug'), 'tenants', ['slug'], unique=True)
|
op.create_index(op.f('ix_tenancy_tenants_slug'), 'tenancy_tenants', ['slug'], unique=True)
|
||||||
op.create_table('attachment_blobs',
|
op.create_table('attachment_blobs',
|
||||||
sa.Column('id', sa.String(length=36), nullable=False),
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
@@ -38,25 +38,25 @@ def upgrade() -> None:
|
|||||||
sa.Column('storage_key', sa.String(length=1000), nullable=False),
|
sa.Column('storage_key', sa.String(length=1000), nullable=False),
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_attachment_blobs_tenant_id_tenants'), ondelete='CASCADE'),
|
sa.ForeignKeyConstraint(['tenant_id'], ['tenancy_tenants.id'], name=op.f('fk_attachment_blobs_tenant_id_tenants'), ondelete='CASCADE'),
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_attachment_blobs')),
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_attachment_blobs')),
|
||||||
sa.UniqueConstraint('tenant_id', 'sha256', name='uq_attachment_blobs_tenant_sha256')
|
sa.UniqueConstraint('tenant_id', 'sha256', name='uq_attachment_blobs_tenant_sha256')
|
||||||
)
|
)
|
||||||
op.create_index(op.f('ix_attachment_blobs_sha256'), 'attachment_blobs', ['sha256'], unique=False)
|
op.create_index(op.f('ix_attachment_blobs_sha256'), 'attachment_blobs', ['sha256'], unique=False)
|
||||||
op.create_index(op.f('ix_attachment_blobs_tenant_id'), 'attachment_blobs', ['tenant_id'], unique=False)
|
op.create_index(op.f('ix_attachment_blobs_tenant_id'), 'attachment_blobs', ['tenant_id'], unique=False)
|
||||||
op.create_table('groups',
|
op.create_table('access_groups',
|
||||||
sa.Column('id', sa.String(length=36), nullable=False),
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
sa.Column('slug', sa.String(length=100), nullable=False),
|
sa.Column('slug', sa.String(length=100), nullable=False),
|
||||||
sa.Column('name', sa.String(length=255), nullable=False),
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_groups_tenant_id_tenants'), ondelete='CASCADE'),
|
sa.ForeignKeyConstraint(['tenant_id'], ['tenancy_tenants.id'], name=op.f('fk_groups_tenant_id_tenants'), ondelete='CASCADE'),
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_groups')),
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_groups')),
|
||||||
sa.UniqueConstraint('tenant_id', 'slug', name='uq_groups_tenant_slug')
|
sa.UniqueConstraint('tenant_id', 'slug', name='uq_groups_tenant_slug')
|
||||||
)
|
)
|
||||||
op.create_index(op.f('ix_groups_tenant_id'), 'groups', ['tenant_id'], unique=False)
|
op.create_index(op.f('ix_access_groups_tenant_id'), 'access_groups', ['tenant_id'], unique=False)
|
||||||
op.create_table('roles',
|
op.create_table('access_roles',
|
||||||
sa.Column('id', sa.String(length=36), nullable=False),
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
sa.Column('tenant_id', sa.String(length=36), nullable=True),
|
sa.Column('tenant_id', sa.String(length=36), nullable=True),
|
||||||
sa.Column('slug', sa.String(length=100), nullable=False),
|
sa.Column('slug', sa.String(length=100), nullable=False),
|
||||||
@@ -64,12 +64,12 @@ def upgrade() -> None:
|
|||||||
sa.Column('permissions', sa.JSON(), nullable=False),
|
sa.Column('permissions', sa.JSON(), nullable=False),
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_roles_tenant_id_tenants'), ondelete='CASCADE'),
|
sa.ForeignKeyConstraint(['tenant_id'], ['tenancy_tenants.id'], name=op.f('fk_roles_tenant_id_tenants'), ondelete='CASCADE'),
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_roles')),
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_roles')),
|
||||||
sa.UniqueConstraint('tenant_id', 'slug', name='uq_roles_tenant_slug')
|
sa.UniqueConstraint('tenant_id', 'slug', name='uq_roles_tenant_slug')
|
||||||
)
|
)
|
||||||
op.create_index(op.f('ix_roles_tenant_id'), 'roles', ['tenant_id'], unique=False)
|
op.create_index(op.f('ix_access_roles_tenant_id'), 'access_roles', ['tenant_id'], unique=False)
|
||||||
op.create_table('users',
|
op.create_table('access_users',
|
||||||
sa.Column('id', sa.String(length=36), nullable=False),
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
sa.Column('email', sa.String(length=320), nullable=False),
|
sa.Column('email', sa.String(length=320), nullable=False),
|
||||||
@@ -78,13 +78,13 @@ def upgrade() -> None:
|
|||||||
sa.Column('is_tenant_admin', sa.Boolean(), nullable=False),
|
sa.Column('is_tenant_admin', sa.Boolean(), nullable=False),
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_users_tenant_id_tenants'), ondelete='CASCADE'),
|
sa.ForeignKeyConstraint(['tenant_id'], ['tenancy_tenants.id'], name=op.f('fk_users_tenant_id_tenants'), ondelete='CASCADE'),
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_users')),
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_users')),
|
||||||
sa.UniqueConstraint('tenant_id', 'email', name='uq_users_tenant_email')
|
sa.UniqueConstraint('tenant_id', 'email', name='uq_users_tenant_email')
|
||||||
)
|
)
|
||||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=False)
|
op.create_index(op.f('ix_access_users_email'), 'access_users', ['email'], unique=False)
|
||||||
op.create_index(op.f('ix_users_tenant_id'), 'users', ['tenant_id'], unique=False)
|
op.create_index(op.f('ix_access_users_tenant_id'), 'access_users', ['tenant_id'], unique=False)
|
||||||
op.create_table('api_keys',
|
op.create_table('access_api_keys',
|
||||||
sa.Column('id', sa.String(length=36), nullable=False),
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
sa.Column('user_id', sa.String(length=36), nullable=False),
|
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||||
@@ -97,13 +97,13 @@ def upgrade() -> None:
|
|||||||
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_api_keys_tenant_id_tenants'), ondelete='CASCADE'),
|
sa.ForeignKeyConstraint(['tenant_id'], ['tenancy_tenants.id'], name=op.f('fk_api_keys_tenant_id_tenants'), ondelete='CASCADE'),
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_api_keys_user_id_users'), ondelete='CASCADE'),
|
sa.ForeignKeyConstraint(['user_id'], ['access_users.id'], name=op.f('fk_api_keys_user_id_users'), ondelete='CASCADE'),
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_api_keys'))
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_api_keys'))
|
||||||
)
|
)
|
||||||
op.create_index(op.f('ix_api_keys_prefix'), 'api_keys', ['prefix'], unique=False)
|
op.create_index(op.f('ix_access_api_keys_prefix'), 'access_api_keys', ['prefix'], unique=False)
|
||||||
op.create_index(op.f('ix_api_keys_tenant_id'), 'api_keys', ['tenant_id'], unique=False)
|
op.create_index(op.f('ix_access_api_keys_tenant_id'), 'access_api_keys', ['tenant_id'], unique=False)
|
||||||
op.create_index(op.f('ix_api_keys_user_id'), 'api_keys', ['user_id'], unique=False)
|
op.create_index(op.f('ix_access_api_keys_user_id'), 'access_api_keys', ['user_id'], unique=False)
|
||||||
op.create_table('campaigns',
|
op.create_table('campaigns',
|
||||||
sa.Column('id', sa.String(length=36), nullable=False),
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
@@ -115,8 +115,8 @@ def upgrade() -> None:
|
|||||||
sa.Column('current_version_id', sa.String(length=36), nullable=True),
|
sa.Column('current_version_id', sa.String(length=36), nullable=True),
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], name=op.f('fk_campaigns_created_by_user_id_users'), ondelete='SET NULL'),
|
sa.ForeignKeyConstraint(['created_by_user_id'], ['access_users.id'], name=op.f('fk_campaigns_created_by_user_id_users'), ondelete='SET NULL'),
|
||||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_campaigns_tenant_id_tenants'), ondelete='CASCADE'),
|
sa.ForeignKeyConstraint(['tenant_id'], ['tenancy_tenants.id'], name=op.f('fk_campaigns_tenant_id_tenants'), ondelete='CASCADE'),
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_campaigns')),
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_campaigns')),
|
||||||
sa.UniqueConstraint('tenant_id', 'external_id', name='uq_campaigns_tenant_external_id')
|
sa.UniqueConstraint('tenant_id', 'external_id', name='uq_campaigns_tenant_external_id')
|
||||||
)
|
)
|
||||||
@@ -138,8 +138,8 @@ def upgrade() -> None:
|
|||||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['blob_id'], ['attachment_blobs.id'], name=op.f('fk_attachment_instances_blob_id_attachment_blobs'), ondelete='CASCADE'),
|
sa.ForeignKeyConstraint(['blob_id'], ['attachment_blobs.id'], name=op.f('fk_attachment_instances_blob_id_attachment_blobs'), ondelete='CASCADE'),
|
||||||
sa.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], name=op.f('fk_attachment_instances_campaign_id_campaigns'), ondelete='CASCADE'),
|
sa.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], name=op.f('fk_attachment_instances_campaign_id_campaigns'), ondelete='CASCADE'),
|
||||||
sa.ForeignKeyConstraint(['owner_user_id'], ['users.id'], name=op.f('fk_attachment_instances_owner_user_id_users'), ondelete='SET NULL'),
|
sa.ForeignKeyConstraint(['owner_user_id'], ['access_users.id'], name=op.f('fk_attachment_instances_owner_user_id_users'), ondelete='SET NULL'),
|
||||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_attachment_instances_tenant_id_tenants'), ondelete='CASCADE'),
|
sa.ForeignKeyConstraint(['tenant_id'], ['tenancy_tenants.id'], name=op.f('fk_attachment_instances_tenant_id_tenants'), ondelete='CASCADE'),
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_attachment_instances'))
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_attachment_instances'))
|
||||||
)
|
)
|
||||||
op.create_index(op.f('ix_attachment_instances_blob_id'), 'attachment_instances', ['blob_id'], unique=False)
|
op.create_index(op.f('ix_attachment_instances_blob_id'), 'attachment_instances', ['blob_id'], unique=False)
|
||||||
@@ -157,9 +157,9 @@ def upgrade() -> None:
|
|||||||
sa.Column('details', sa.JSON(), nullable=True),
|
sa.Column('details', sa.JSON(), nullable=True),
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['api_key_id'], ['api_keys.id'], name=op.f('fk_audit_log_api_key_id_api_keys'), ondelete='SET NULL'),
|
sa.ForeignKeyConstraint(['api_key_id'], ['access_api_keys.id'], name=op.f('fk_audit_log_api_key_id_api_keys'), ondelete='SET NULL'),
|
||||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_audit_log_tenant_id_tenants'), ondelete='CASCADE'),
|
sa.ForeignKeyConstraint(['tenant_id'], ['tenancy_tenants.id'], name=op.f('fk_audit_log_tenant_id_tenants'), ondelete='CASCADE'),
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_audit_log_user_id_users'), ondelete='SET NULL'),
|
sa.ForeignKeyConstraint(['user_id'], ['access_users.id'], name=op.f('fk_audit_log_user_id_users'), ondelete='SET NULL'),
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_audit_log'))
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_audit_log'))
|
||||||
)
|
)
|
||||||
op.create_index(op.f('ix_audit_log_action'), 'audit_log', ['action'], unique=False)
|
op.create_index(op.f('ix_audit_log_action'), 'audit_log', ['action'], unique=False)
|
||||||
@@ -213,7 +213,7 @@ def upgrade() -> None:
|
|||||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], name=op.f('fk_campaign_jobs_campaign_id_campaigns'), ondelete='CASCADE'),
|
sa.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], name=op.f('fk_campaign_jobs_campaign_id_campaigns'), ondelete='CASCADE'),
|
||||||
sa.ForeignKeyConstraint(['campaign_version_id'], ['campaign_versions.id'], name=op.f('fk_campaign_jobs_campaign_version_id_campaign_versions'), ondelete='CASCADE'),
|
sa.ForeignKeyConstraint(['campaign_version_id'], ['campaign_versions.id'], name=op.f('fk_campaign_jobs_campaign_version_id_campaign_versions'), ondelete='CASCADE'),
|
||||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_campaign_jobs_tenant_id_tenants'), ondelete='CASCADE'),
|
sa.ForeignKeyConstraint(['tenant_id'], ['tenancy_tenants.id'], name=op.f('fk_campaign_jobs_tenant_id_tenants'), ondelete='CASCADE'),
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_campaign_jobs')),
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_campaign_jobs')),
|
||||||
sa.UniqueConstraint('campaign_version_id', 'entry_index', name='uq_campaign_jobs_version_entry')
|
sa.UniqueConstraint('campaign_version_id', 'entry_index', name='uq_campaign_jobs_version_entry')
|
||||||
)
|
)
|
||||||
@@ -243,7 +243,7 @@ def upgrade() -> None:
|
|||||||
sa.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], name=op.f('fk_campaign_issues_campaign_id_campaigns'), ondelete='CASCADE'),
|
sa.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], name=op.f('fk_campaign_issues_campaign_id_campaigns'), ondelete='CASCADE'),
|
||||||
sa.ForeignKeyConstraint(['campaign_version_id'], ['campaign_versions.id'], name=op.f('fk_campaign_issues_campaign_version_id_campaign_versions'), ondelete='CASCADE'),
|
sa.ForeignKeyConstraint(['campaign_version_id'], ['campaign_versions.id'], name=op.f('fk_campaign_issues_campaign_version_id_campaign_versions'), ondelete='CASCADE'),
|
||||||
sa.ForeignKeyConstraint(['job_id'], ['campaign_jobs.id'], name=op.f('fk_campaign_issues_job_id_campaign_jobs'), ondelete='CASCADE'),
|
sa.ForeignKeyConstraint(['job_id'], ['campaign_jobs.id'], name=op.f('fk_campaign_issues_job_id_campaign_jobs'), ondelete='CASCADE'),
|
||||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_campaign_issues_tenant_id_tenants'), ondelete='CASCADE'),
|
sa.ForeignKeyConstraint(['tenant_id'], ['tenancy_tenants.id'], name=op.f('fk_campaign_issues_tenant_id_tenants'), ondelete='CASCADE'),
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_campaign_issues'))
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_campaign_issues'))
|
||||||
)
|
)
|
||||||
op.create_index(op.f('ix_campaign_issues_campaign_id'), 'campaign_issues', ['campaign_id'], unique=False)
|
op.create_index(op.f('ix_campaign_issues_campaign_id'), 'campaign_issues', ['campaign_id'], unique=False)
|
||||||
@@ -327,20 +327,20 @@ def downgrade() -> None:
|
|||||||
op.drop_index(op.f('ix_campaigns_external_id'), table_name='campaigns')
|
op.drop_index(op.f('ix_campaigns_external_id'), table_name='campaigns')
|
||||||
op.drop_index(op.f('ix_campaigns_created_by_user_id'), table_name='campaigns')
|
op.drop_index(op.f('ix_campaigns_created_by_user_id'), table_name='campaigns')
|
||||||
op.drop_table('campaigns')
|
op.drop_table('campaigns')
|
||||||
op.drop_index(op.f('ix_api_keys_user_id'), table_name='api_keys')
|
op.drop_index(op.f('ix_access_api_keys_user_id'), table_name='access_api_keys')
|
||||||
op.drop_index(op.f('ix_api_keys_tenant_id'), table_name='api_keys')
|
op.drop_index(op.f('ix_access_api_keys_tenant_id'), table_name='access_api_keys')
|
||||||
op.drop_index(op.f('ix_api_keys_prefix'), table_name='api_keys')
|
op.drop_index(op.f('ix_access_api_keys_prefix'), table_name='access_api_keys')
|
||||||
op.drop_table('api_keys')
|
op.drop_table('access_api_keys')
|
||||||
op.drop_index(op.f('ix_users_tenant_id'), table_name='users')
|
op.drop_index(op.f('ix_access_users_tenant_id'), table_name='access_users')
|
||||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
op.drop_index(op.f('ix_access_users_email'), table_name='access_users')
|
||||||
op.drop_table('users')
|
op.drop_table('access_users')
|
||||||
op.drop_index(op.f('ix_roles_tenant_id'), table_name='roles')
|
op.drop_index(op.f('ix_access_roles_tenant_id'), table_name='access_roles')
|
||||||
op.drop_table('roles')
|
op.drop_table('access_roles')
|
||||||
op.drop_index(op.f('ix_groups_tenant_id'), table_name='groups')
|
op.drop_index(op.f('ix_access_groups_tenant_id'), table_name='access_groups')
|
||||||
op.drop_table('groups')
|
op.drop_table('access_groups')
|
||||||
op.drop_index(op.f('ix_attachment_blobs_tenant_id'), table_name='attachment_blobs')
|
op.drop_index(op.f('ix_attachment_blobs_tenant_id'), table_name='attachment_blobs')
|
||||||
op.drop_index(op.f('ix_attachment_blobs_sha256'), table_name='attachment_blobs')
|
op.drop_index(op.f('ix_attachment_blobs_sha256'), table_name='attachment_blobs')
|
||||||
op.drop_table('attachment_blobs')
|
op.drop_table('attachment_blobs')
|
||||||
op.drop_index(op.f('ix_tenants_slug'), table_name='tenants')
|
op.drop_index(op.f('ix_tenancy_tenants_slug'), table_name='tenancy_tenants')
|
||||||
op.drop_table('tenants')
|
op.drop_table('tenancy_tenants')
|
||||||
# ### end Alembic commands ###
|
# ### end Alembic commands ###
|
||||||
@@ -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
|
||||||
+9
-9
@@ -19,10 +19,10 @@ def upgrade() -> None:
|
|||||||
bind = op.get_bind()
|
bind = op.get_bind()
|
||||||
inspector = sa.inspect(bind)
|
inspector = sa.inspect(bind)
|
||||||
tables = set(inspector.get_table_names())
|
tables = set(inspector.get_table_names())
|
||||||
if "auth_sessions" in tables:
|
if "access_auth_sessions" in tables:
|
||||||
columns = {column["name"] for column in inspector.get_columns("auth_sessions")}
|
columns = {column["name"] for column in inspector.get_columns("access_auth_sessions")}
|
||||||
if "csrf_token_hash" not in columns:
|
if "csrf_token_hash" not in columns:
|
||||||
op.add_column("auth_sessions", sa.Column("csrf_token_hash", sa.String(length=128), nullable=True))
|
op.add_column("access_auth_sessions", sa.Column("csrf_token_hash", sa.String(length=128), nullable=True))
|
||||||
if "mail_server_profiles" not in tables:
|
if "mail_server_profiles" not in tables:
|
||||||
op.create_table(
|
op.create_table(
|
||||||
"mail_server_profiles",
|
"mail_server_profiles",
|
||||||
@@ -40,9 +40,9 @@ def upgrade() -> None:
|
|||||||
sa.Column("updated_by_user_id", sa.String(length=36), nullable=True),
|
sa.Column("updated_by_user_id", sa.String(length=36), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
|
||||||
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.PrimaryKeyConstraint("id"),
|
||||||
sa.UniqueConstraint("tenant_id", "slug", name="uq_mail_server_profiles_tenant_slug"),
|
sa.UniqueConstraint("tenant_id", "slug", name="uq_mail_server_profiles_tenant_slug"),
|
||||||
)
|
)
|
||||||
@@ -67,7 +67,7 @@ def downgrade() -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
op.drop_table("mail_server_profiles")
|
op.drop_table("mail_server_profiles")
|
||||||
if "auth_sessions" in inspector.get_table_names():
|
if "access_auth_sessions" in inspector.get_table_names():
|
||||||
columns = {column["name"] for column in inspector.get_columns("auth_sessions")}
|
columns = {column["name"] for column in inspector.get_columns("access_auth_sessions")}
|
||||||
if "csrf_token_hash" in columns:
|
if "csrf_token_hash" in columns:
|
||||||
op.drop_column("auth_sessions", "csrf_token_hash")
|
op.drop_column("access_auth_sessions", "csrf_token_hash")
|
||||||
@@ -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
|
||||||
+2
-2
@@ -31,7 +31,7 @@ def upgrade() -> None:
|
|||||||
inspector = sa.inspect(bind)
|
inspector = sa.inspect(bind)
|
||||||
tables = set(inspector.get_table_names())
|
tables = set(inspector.get_table_names())
|
||||||
|
|
||||||
for table_name in ("users", "groups", "campaigns"):
|
for table_name in ("access_users", "access_groups", "campaigns"):
|
||||||
if table_name not in tables:
|
if table_name not in tables:
|
||||||
continue
|
continue
|
||||||
columns = _columns(inspector, table_name)
|
columns = _columns(inspector, table_name)
|
||||||
@@ -83,6 +83,6 @@ def downgrade() -> None:
|
|||||||
batch.drop_column("scope_type")
|
batch.drop_column("scope_type")
|
||||||
batch.alter_column("tenant_id", existing_type=sa.String(length=36), nullable=False)
|
batch.alter_column("tenant_id", existing_type=sa.String(length=36), nullable=False)
|
||||||
|
|
||||||
for table_name in ("campaigns", "groups", "users"):
|
for table_name in ("campaigns", "access_groups", "access_users"):
|
||||||
if table_name in tables and "mail_profile_policy" in _columns(inspector, table_name):
|
if table_name in tables and "mail_profile_policy" in _columns(inspector, table_name):
|
||||||
op.drop_column(table_name, "mail_profile_policy")
|
op.drop_column(table_name, "mail_profile_policy")
|
||||||
@@ -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
|
||||||
+2
-2
@@ -25,7 +25,7 @@ def upgrade() -> None:
|
|||||||
bind = op.get_bind()
|
bind = op.get_bind()
|
||||||
inspector = sa.inspect(bind)
|
inspector = sa.inspect(bind)
|
||||||
tables = set(inspector.get_table_names())
|
tables = set(inspector.get_table_names())
|
||||||
for table_name in ("users", "groups", "campaigns"):
|
for table_name in ("access_users", "access_groups", "campaigns"):
|
||||||
if table_name not in tables:
|
if table_name not in tables:
|
||||||
continue
|
continue
|
||||||
if "settings" not in _columns(inspector, table_name):
|
if "settings" not in _columns(inspector, table_name):
|
||||||
@@ -36,6 +36,6 @@ def downgrade() -> None:
|
|||||||
bind = op.get_bind()
|
bind = op.get_bind()
|
||||||
inspector = sa.inspect(bind)
|
inspector = sa.inspect(bind)
|
||||||
tables = set(inspector.get_table_names())
|
tables = set(inspector.get_table_names())
|
||||||
for table_name in ("campaigns", "groups", "users"):
|
for table_name in ("campaigns", "access_groups", "access_users"):
|
||||||
if table_name in tables and "settings" in _columns(inspector, table_name):
|
if table_name in tables and "settings" in _columns(inspector, table_name):
|
||||||
op.drop_column(table_name, "settings")
|
op.drop_column(table_name, "settings")
|
||||||
+22
-6
@@ -5,29 +5,45 @@ from logging.config import fileConfig
|
|||||||
from alembic import context
|
from alembic import context
|
||||||
from sqlalchemy import engine_from_config, pool
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
from govoplan_core.access.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.core.migrations import migration_metadata_plan
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_core.db import models # noqa: F401 - populate core metadata
|
|
||||||
from govoplan_core.server.default_config import get_server_config
|
from govoplan_core.server.default_config import get_server_config
|
||||||
from govoplan_core.server.registry import build_platform_registry
|
from govoplan_core.server.registry import build_platform_registry
|
||||||
from govoplan_core.settings import settings
|
from govoplan_core.settings import settings
|
||||||
|
from govoplan_core.tenancy.scope import scope_registry
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
database_url = config.attributes.get("database_url") or settings.database_url
|
database_url = config.attributes.get("database_url") or settings.database_url
|
||||||
config.set_main_option("sqlalchemy.url", database_url)
|
config.set_main_option("sqlalchemy.url", database_url)
|
||||||
|
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
fileConfig(config.config_file_name)
|
# Migrations can run inside the long-lived application process when module
|
||||||
|
# state changes. Do not let Alembic's logging setup disable loggers that the
|
||||||
|
# server already created (for example slow-request diagnostics).
|
||||||
|
fileConfig(config.config_file_name, disable_existing_loggers=False)
|
||||||
|
|
||||||
|
|
||||||
def _target_metadata():
|
def _target_metadata():
|
||||||
server_config = get_server_config()
|
server_config = get_server_config()
|
||||||
|
enabled_modules = config.attributes.get("enabled_modules", server_config.enabled_modules)
|
||||||
|
manifest_factories = config.attributes.get("manifest_factories", server_config.manifest_factories)
|
||||||
registry = build_platform_registry(
|
registry = build_platform_registry(
|
||||||
server_config.enabled_modules,
|
enabled_modules,
|
||||||
manifest_factories=server_config.manifest_factories,
|
manifest_factories=manifest_factories,
|
||||||
)
|
)
|
||||||
plan = migration_metadata_plan(registry, extra_metadata=(Base.metadata,))
|
plan = migration_metadata_plan(registry, extra_metadata=(scope_registry.metadata, Base.metadata))
|
||||||
return tuple(dict.fromkeys(plan.metadata))
|
return tuple(dict.fromkeys(plan.metadata))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
"""auth sessions and RBAC assignments
|
|
||||||
|
|
||||||
Revision ID: 2c3d4e5f6a7b
|
|
||||||
Revises: 1f8d4c2a0b7e
|
|
||||||
Create Date: 2026-06-08 10:00:00.000000
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
revision = "2c3d4e5f6a7b"
|
|
||||||
down_revision = "1f8d4c2a0b7e"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
with op.batch_alter_table("users") as batch_op:
|
|
||||||
batch_op.add_column(sa.Column("auth_provider", sa.String(length=50), nullable=False, server_default="local"))
|
|
||||||
batch_op.add_column(sa.Column("password_hash", sa.String(length=500), nullable=True))
|
|
||||||
batch_op.add_column(sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True))
|
|
||||||
|
|
||||||
op.create_table(
|
|
||||||
"user_group_memberships",
|
|
||||||
sa.Column("id", sa.String(length=36), nullable=False),
|
|
||||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
|
||||||
sa.Column("user_id", sa.String(length=36), nullable=False),
|
|
||||||
sa.Column("group_id", sa.String(length=36), nullable=False),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
|
||||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
|
||||||
sa.ForeignKeyConstraint(["group_id"], ["groups.id"], ondelete="CASCADE"),
|
|
||||||
sa.PrimaryKeyConstraint("id"),
|
|
||||||
sa.UniqueConstraint("tenant_id", "user_id", "group_id", name="uq_user_group_memberships"),
|
|
||||||
)
|
|
||||||
op.create_index(op.f("ix_user_group_memberships_tenant_id"), "user_group_memberships", ["tenant_id"])
|
|
||||||
op.create_index(op.f("ix_user_group_memberships_user_id"), "user_group_memberships", ["user_id"])
|
|
||||||
op.create_index(op.f("ix_user_group_memberships_group_id"), "user_group_memberships", ["group_id"])
|
|
||||||
|
|
||||||
op.create_table(
|
|
||||||
"user_role_assignments",
|
|
||||||
sa.Column("id", sa.String(length=36), nullable=False),
|
|
||||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
|
||||||
sa.Column("user_id", sa.String(length=36), nullable=False),
|
|
||||||
sa.Column("role_id", sa.String(length=36), nullable=False),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
|
||||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
|
||||||
sa.ForeignKeyConstraint(["role_id"], ["roles.id"], ondelete="CASCADE"),
|
|
||||||
sa.PrimaryKeyConstraint("id"),
|
|
||||||
sa.UniqueConstraint("tenant_id", "user_id", "role_id", name="uq_user_role_assignments"),
|
|
||||||
)
|
|
||||||
op.create_index(op.f("ix_user_role_assignments_tenant_id"), "user_role_assignments", ["tenant_id"])
|
|
||||||
op.create_index(op.f("ix_user_role_assignments_user_id"), "user_role_assignments", ["user_id"])
|
|
||||||
op.create_index(op.f("ix_user_role_assignments_role_id"), "user_role_assignments", ["role_id"])
|
|
||||||
|
|
||||||
op.create_table(
|
|
||||||
"group_role_assignments",
|
|
||||||
sa.Column("id", sa.String(length=36), nullable=False),
|
|
||||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
|
||||||
sa.Column("group_id", sa.String(length=36), nullable=False),
|
|
||||||
sa.Column("role_id", sa.String(length=36), nullable=False),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
|
||||||
sa.ForeignKeyConstraint(["group_id"], ["groups.id"], ondelete="CASCADE"),
|
|
||||||
sa.ForeignKeyConstraint(["role_id"], ["roles.id"], ondelete="CASCADE"),
|
|
||||||
sa.PrimaryKeyConstraint("id"),
|
|
||||||
sa.UniqueConstraint("tenant_id", "group_id", "role_id", name="uq_group_role_assignments"),
|
|
||||||
)
|
|
||||||
op.create_index(op.f("ix_group_role_assignments_tenant_id"), "group_role_assignments", ["tenant_id"])
|
|
||||||
op.create_index(op.f("ix_group_role_assignments_group_id"), "group_role_assignments", ["group_id"])
|
|
||||||
op.create_index(op.f("ix_group_role_assignments_role_id"), "group_role_assignments", ["role_id"])
|
|
||||||
|
|
||||||
op.create_table(
|
|
||||||
"auth_sessions",
|
|
||||||
sa.Column("id", sa.String(length=36), nullable=False),
|
|
||||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
|
||||||
sa.Column("user_id", sa.String(length=36), nullable=False),
|
|
||||||
sa.Column("token_hash", sa.String(length=128), nullable=False),
|
|
||||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column("user_agent", sa.String(length=500), nullable=True),
|
|
||||||
sa.Column("ip_address", sa.String(length=100), 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"], ["tenants.id"], ondelete="CASCADE"),
|
|
||||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
|
||||||
sa.PrimaryKeyConstraint("id"),
|
|
||||||
sa.UniqueConstraint("token_hash"),
|
|
||||||
)
|
|
||||||
op.create_index(op.f("ix_auth_sessions_tenant_id"), "auth_sessions", ["tenant_id"])
|
|
||||||
op.create_index(op.f("ix_auth_sessions_user_id"), "auth_sessions", ["user_id"])
|
|
||||||
op.create_index(op.f("ix_auth_sessions_token_hash"), "auth_sessions", ["token_hash"])
|
|
||||||
op.create_index(op.f("ix_auth_sessions_expires_at"), "auth_sessions", ["expires_at"])
|
|
||||||
op.create_index(op.f("ix_auth_sessions_revoked_at"), "auth_sessions", ["revoked_at"])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_index(op.f("ix_auth_sessions_revoked_at"), table_name="auth_sessions")
|
|
||||||
op.drop_index(op.f("ix_auth_sessions_expires_at"), table_name="auth_sessions")
|
|
||||||
op.drop_index(op.f("ix_auth_sessions_token_hash"), table_name="auth_sessions")
|
|
||||||
op.drop_index(op.f("ix_auth_sessions_user_id"), table_name="auth_sessions")
|
|
||||||
op.drop_index(op.f("ix_auth_sessions_tenant_id"), table_name="auth_sessions")
|
|
||||||
op.drop_table("auth_sessions")
|
|
||||||
|
|
||||||
op.drop_index(op.f("ix_group_role_assignments_role_id"), table_name="group_role_assignments")
|
|
||||||
op.drop_index(op.f("ix_group_role_assignments_group_id"), table_name="group_role_assignments")
|
|
||||||
op.drop_index(op.f("ix_group_role_assignments_tenant_id"), table_name="group_role_assignments")
|
|
||||||
op.drop_table("group_role_assignments")
|
|
||||||
|
|
||||||
op.drop_index(op.f("ix_user_role_assignments_role_id"), table_name="user_role_assignments")
|
|
||||||
op.drop_index(op.f("ix_user_role_assignments_user_id"), table_name="user_role_assignments")
|
|
||||||
op.drop_index(op.f("ix_user_role_assignments_tenant_id"), table_name="user_role_assignments")
|
|
||||||
op.drop_table("user_role_assignments")
|
|
||||||
|
|
||||||
op.drop_index(op.f("ix_user_group_memberships_group_id"), table_name="user_group_memberships")
|
|
||||||
op.drop_index(op.f("ix_user_group_memberships_user_id"), table_name="user_group_memberships")
|
|
||||||
op.drop_index(op.f("ix_user_group_memberships_tenant_id"), table_name="user_group_memberships")
|
|
||||||
op.drop_table("user_group_memberships")
|
|
||||||
|
|
||||||
with op.batch_alter_table("users") as batch_op:
|
|
||||||
batch_op.drop_column("last_login_at")
|
|
||||||
batch_op.drop_column("password_hash")
|
|
||||||
batch_op.drop_column("auth_provider")
|
|
||||||
@@ -0,0 +1,892 @@
|
|||||||
|
"""v0.1.7 core baseline
|
||||||
|
|
||||||
|
Revision ID: 4f2a9c8e7b6d
|
||||||
|
Revises: None
|
||||||
|
Create Date: 2026-07-11 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from uuid import uuid4
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = '4f2a9c8e7b6d'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_core_defaults() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
now = _now()
|
||||||
|
if not bind.execute(sa.text("SELECT 1 FROM core_system_settings WHERE id = 'global'")).first():
|
||||||
|
settings_table = sa.table(
|
||||||
|
"core_system_settings",
|
||||||
|
sa.column("id", sa.String),
|
||||||
|
sa.column("default_locale", sa.String),
|
||||||
|
sa.column("allow_tenant_custom_groups", sa.Boolean),
|
||||||
|
sa.column("allow_tenant_custom_roles", sa.Boolean),
|
||||||
|
sa.column("allow_tenant_api_keys", sa.Boolean),
|
||||||
|
sa.column("settings", sa.JSON),
|
||||||
|
sa.column("created_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.column("updated_at", sa.DateTime(timezone=True)),
|
||||||
|
)
|
||||||
|
bind.execute(
|
||||||
|
settings_table.insert().values({
|
||||||
|
"id": "global",
|
||||||
|
"default_locale": "en",
|
||||||
|
"allow_tenant_custom_groups": True,
|
||||||
|
"allow_tenant_custom_roles": True,
|
||||||
|
"allow_tenant_api_keys": True,
|
||||||
|
"settings": {},
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
system_roles = {
|
||||||
|
"system_owner": {
|
||||||
|
"name": "System owner",
|
||||||
|
"description": "Protected full instance-wide administration.",
|
||||||
|
"permissions": ["system:*"],
|
||||||
|
"is_builtin": True,
|
||||||
|
},
|
||||||
|
"system_admin": {
|
||||||
|
"name": "System administrator",
|
||||||
|
"description": "Manage the instance without granting the protected System owner role.",
|
||||||
|
"permissions": [
|
||||||
|
"system:tenants:read", "system:tenants:create", "system:tenants:update", "system:tenants:suspend",
|
||||||
|
"system:accounts:read", "system:accounts:create", "system:accounts:update", "system:accounts:suspend",
|
||||||
|
"system:roles:read", "system:roles:write", "system:roles:assign", "system:access:read", "system:access:assign",
|
||||||
|
"system:audit:read", "system:settings:read", "system:settings:write", "system:governance:read", "system:governance:write",
|
||||||
|
],
|
||||||
|
"is_builtin": False,
|
||||||
|
},
|
||||||
|
"system_auditor": {
|
||||||
|
"name": "System auditor",
|
||||||
|
"description": "Read-only access to system administration and audit.",
|
||||||
|
"permissions": [
|
||||||
|
"system:tenants:read", "system:accounts:read", "system:roles:read", "system:access:read",
|
||||||
|
"system:audit:read", "system:settings:read", "system:governance:read",
|
||||||
|
],
|
||||||
|
"is_builtin": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
roles_table = sa.table(
|
||||||
|
"access_roles",
|
||||||
|
sa.column("id", sa.String),
|
||||||
|
sa.column("tenant_id", sa.String),
|
||||||
|
sa.column("slug", sa.String),
|
||||||
|
sa.column("name", sa.String),
|
||||||
|
sa.column("description", sa.Text),
|
||||||
|
sa.column("permissions", sa.JSON),
|
||||||
|
sa.column("is_builtin", sa.Boolean),
|
||||||
|
sa.column("is_assignable", sa.Boolean),
|
||||||
|
sa.column("system_template_id", sa.String),
|
||||||
|
sa.column("system_required", sa.Boolean),
|
||||||
|
sa.column("created_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.column("updated_at", sa.DateTime(timezone=True)),
|
||||||
|
)
|
||||||
|
for slug, role in system_roles.items():
|
||||||
|
if bind.execute(
|
||||||
|
sa.text("SELECT 1 FROM access_roles WHERE tenant_id IS NULL AND slug = :slug"),
|
||||||
|
{"slug": slug},
|
||||||
|
).first():
|
||||||
|
continue
|
||||||
|
bind.execute(
|
||||||
|
roles_table.insert().values(
|
||||||
|
id=str(uuid4()),
|
||||||
|
tenant_id=None,
|
||||||
|
slug=slug,
|
||||||
|
name=role["name"],
|
||||||
|
description=role["description"],
|
||||||
|
permissions=role["permissions"],
|
||||||
|
is_builtin=bool(role["is_builtin"]),
|
||||||
|
is_assignable=True,
|
||||||
|
system_template_id=None,
|
||||||
|
system_required=False,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table('core_scopes',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('slug', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('default_locale', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('settings', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('allow_custom_groups', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('allow_custom_roles', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('allow_api_keys', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), 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_scopes'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_core_scopes_slug'), 'core_scopes', ['slug'], unique=True)
|
||||||
|
op.create_table('access_accounts',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=320), nullable=False),
|
||||||
|
sa.Column('normalized_email', sa.String(length=320), nullable=False),
|
||||||
|
sa.Column('display_name', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('auth_provider', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('password_hash', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('password_reset_required', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('last_login_at', sa.DateTime(timezone=True), 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_access_accounts'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_access_accounts_normalized_email'), 'access_accounts', ['normalized_email'], unique=True)
|
||||||
|
op.create_table('access_groups',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('slug', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('system_template_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('system_required', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('settings', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('mail_profile_policy', 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_access_groups')),
|
||||||
|
sa.UniqueConstraint('tenant_id', 'slug', name='uq_groups_tenant_slug')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_access_groups_system_template_id'), 'access_groups', ['system_template_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_access_groups_tenant_id'), 'access_groups', ['tenant_id'], unique=False)
|
||||||
|
op.create_table('access_roles',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('slug', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('permissions', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('is_builtin', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('is_assignable', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('system_template_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('system_required', sa.Boolean(), 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_access_roles')),
|
||||||
|
sa.UniqueConstraint('tenant_id', 'slug', name='uq_roles_tenant_slug')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_access_roles_system_template_id'), 'access_roles', ['system_template_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_access_roles_tenant_id'), 'access_roles', ['tenant_id'], unique=False)
|
||||||
|
op.create_index('uq_roles_system_slug', 'access_roles', ['slug'], unique=True, sqlite_where=sa.text('tenant_id IS NULL'), postgresql_where=sa.text('tenant_id IS NULL'))
|
||||||
|
op.create_table('admin_governance_templates',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('kind', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('slug', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('permissions', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), 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_admin_governance_templates')),
|
||||||
|
sa.UniqueConstraint('kind', 'slug', name='uq_governance_templates_kind_slug')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_admin_governance_templates_kind'), 'admin_governance_templates', ['kind'], unique=False)
|
||||||
|
op.create_table('attachment_blobs',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('sha256', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('size_bytes', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('mime_type', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('storage_bucket', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('storage_key', sa.String(length=1000), 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_attachment_blobs')),
|
||||||
|
sa.UniqueConstraint('tenant_id', 'sha256', name='uq_attachment_blobs_tenant_sha256')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_attachment_blobs_sha256'), 'attachment_blobs', ['sha256'], unique=False)
|
||||||
|
op.create_index(op.f('ix_attachment_blobs_tenant_id'), 'attachment_blobs', ['tenant_id'], unique=False)
|
||||||
|
op.create_table('audit_outbox_events',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('event_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('event_type', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('module_id', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('correlation_id', sa.String(length=128), nullable=True),
|
||||||
|
sa.Column('causation_id', sa.String(length=128), nullable=True),
|
||||||
|
sa.Column('classification', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('payload', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('attempts', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('next_attempt_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('dispatched_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('last_error', 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_audit_outbox_events')),
|
||||||
|
sa.UniqueConstraint('event_id', name='uq_audit_outbox_events_event_id')
|
||||||
|
)
|
||||||
|
op.create_index('ix_audit_outbox_events_correlation_id', 'audit_outbox_events', ['correlation_id'], unique=False)
|
||||||
|
op.create_index('ix_audit_outbox_events_event_type', 'audit_outbox_events', ['event_type'], unique=False)
|
||||||
|
op.create_index(op.f('ix_audit_outbox_events_status'), 'audit_outbox_events', ['status'], unique=False)
|
||||||
|
op.create_index('ix_audit_outbox_events_status_next_attempt_at', 'audit_outbox_events', ['status', 'next_attempt_at'], unique=False)
|
||||||
|
op.create_table('core_change_sequence',
|
||||||
|
sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('module_id', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('collection', sa.String(length=150), nullable=False),
|
||||||
|
sa.Column('resource_type', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('resource_id', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('operation', sa.String(length=30), nullable=False),
|
||||||
|
sa.Column('actor_type', sa.String(length=30), nullable=True),
|
||||||
|
sa.Column('actor_id', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('payload', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_core_change_sequence'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_core_change_sequence_actor_id'), 'core_change_sequence', ['actor_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_core_change_sequence_actor_type'), 'core_change_sequence', ['actor_type'], unique=False)
|
||||||
|
op.create_index(op.f('ix_core_change_sequence_collection'), 'core_change_sequence', ['collection'], unique=False)
|
||||||
|
op.create_index('ix_core_change_sequence_collection_id', 'core_change_sequence', ['collection', 'id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_core_change_sequence_created_at'), 'core_change_sequence', ['created_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_core_change_sequence_module_id'), 'core_change_sequence', ['module_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_core_change_sequence_operation'), 'core_change_sequence', ['operation'], unique=False)
|
||||||
|
op.create_index('ix_core_change_sequence_resource', 'core_change_sequence', ['module_id', 'resource_type', 'resource_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_core_change_sequence_resource_id'), 'core_change_sequence', ['resource_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_core_change_sequence_resource_type'), 'core_change_sequence', ['resource_type'], unique=False)
|
||||||
|
op.create_index(op.f('ix_core_change_sequence_tenant_id'), 'core_change_sequence', ['tenant_id'], unique=False)
|
||||||
|
op.create_index('ix_core_change_sequence_tenant_module_id', 'core_change_sequence', ['tenant_id', 'module_id', 'id'], unique=False)
|
||||||
|
op.create_table('core_change_sequence_retention_floor',
|
||||||
|
sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('tenant_key', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('module_id', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('collection', sa.String(length=150), nullable=False),
|
||||||
|
sa.Column('min_valid_sequence', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_core_change_sequence_retention_floor')),
|
||||||
|
sa.UniqueConstraint('tenant_key', 'module_id', 'collection', name='uq_core_change_sequence_retention_scope')
|
||||||
|
)
|
||||||
|
op.create_index('ix_core_change_sequence_retention_scope', 'core_change_sequence_retention_floor', ['tenant_key', 'module_id', 'collection'], unique=False)
|
||||||
|
op.create_table('core_system_settings',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('default_locale', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('allow_tenant_custom_groups', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('allow_tenant_custom_roles', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('allow_tenant_api_keys', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('settings', 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_system_settings'))
|
||||||
|
)
|
||||||
|
op.create_table('file_blobs',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('storage_backend', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('storage_bucket', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('storage_key', sa.String(length=1000), nullable=False),
|
||||||
|
sa.Column('checksum_sha256', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('size_bytes', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('content_type', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('ref_count', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('retained_until', sa.DateTime(timezone=True), 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_file_blobs')),
|
||||||
|
sa.UniqueConstraint('tenant_id', 'checksum_sha256', 'size_bytes', name='uq_file_blobs_tenant_checksum_size')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_file_blobs_checksum_sha256'), 'file_blobs', ['checksum_sha256'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_blobs_tenant_id'), 'file_blobs', ['tenant_id'], unique=False)
|
||||||
|
op.create_table('access_group_role_assignments',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('group_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('role_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['group_id'], ['access_groups.id'], name=op.f('fk_access_group_role_assignments_group_id_access_groups'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['role_id'], ['access_roles.id'], name=op.f('fk_access_group_role_assignments_role_id_access_roles'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_group_role_assignments')),
|
||||||
|
sa.UniqueConstraint('tenant_id', 'group_id', 'role_id', name='uq_group_role_assignments')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_access_group_role_assignments_group_id'), 'access_group_role_assignments', ['group_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_access_group_role_assignments_role_id'), 'access_group_role_assignments', ['role_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_access_group_role_assignments_tenant_id'), 'access_group_role_assignments', ['tenant_id'], unique=False)
|
||||||
|
op.create_table('access_system_role_assignments',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('account_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('role_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['account_id'], ['access_accounts.id'], name=op.f('fk_access_system_role_assignments_account_id_access_accounts'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['role_id'], ['access_roles.id'], name=op.f('fk_access_system_role_assignments_role_id_access_roles'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_system_role_assignments')),
|
||||||
|
sa.UniqueConstraint('account_id', 'role_id', name='uq_system_role_assignments')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_access_system_role_assignments_account_id'), 'access_system_role_assignments', ['account_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_access_system_role_assignments_role_id'), 'access_system_role_assignments', ['role_id'], unique=False)
|
||||||
|
op.create_table('access_users',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('account_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=320), nullable=False),
|
||||||
|
sa.Column('display_name', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('is_tenant_admin', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('auth_provider', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('password_hash', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('settings', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('mail_profile_policy', 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.ForeignKeyConstraint(['account_id'], ['access_accounts.id'], name=op.f('fk_access_users_account_id_access_accounts'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_users')),
|
||||||
|
sa.UniqueConstraint('tenant_id', 'account_id', name='uq_users_tenant_account'),
|
||||||
|
sa.UniqueConstraint('tenant_id', 'email', name='uq_users_tenant_email')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_access_users_account_id'), 'access_users', ['account_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_access_users_email'), 'access_users', ['email'], unique=False)
|
||||||
|
op.create_index(op.f('ix_access_users_tenant_id'), 'access_users', ['tenant_id'], unique=False)
|
||||||
|
op.create_table('admin_governance_template_assignments',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('template_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('mode', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['template_id'], ['admin_governance_templates.id'], name=op.f('fk_admin_governance_template_assignments_template_id_admin_governance_templates'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_admin_governance_template_assignments')),
|
||||||
|
sa.UniqueConstraint('template_id', 'tenant_id', name='uq_governance_template_tenant')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_admin_governance_template_assignments_template_id'), 'admin_governance_template_assignments', ['template_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_admin_governance_template_assignments_tenant_id'), 'admin_governance_template_assignments', ['tenant_id'], unique=False)
|
||||||
|
op.create_table('access_api_keys',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('prefix', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('key_hash', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('scopes', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['access_users.id'], name=op.f('fk_access_api_keys_user_id_access_users'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_api_keys'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_access_api_keys_prefix'), 'access_api_keys', ['prefix'], unique=False)
|
||||||
|
op.create_index(op.f('ix_access_api_keys_tenant_id'), 'access_api_keys', ['tenant_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_access_api_keys_user_id'), 'access_api_keys', ['user_id'], unique=False)
|
||||||
|
op.create_table('access_auth_sessions',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('account_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('token_hash', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('csrf_token_hash', sa.String(length=128), nullable=True),
|
||||||
|
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('last_seen_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('user_agent', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('ip_address', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['account_id'], ['access_accounts.id'], name=op.f('fk_access_auth_sessions_account_id_access_accounts'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['access_users.id'], name=op.f('fk_access_auth_sessions_user_id_access_users'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_auth_sessions'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_access_auth_sessions_account_id'), 'access_auth_sessions', ['account_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_access_auth_sessions_expires_at'), 'access_auth_sessions', ['expires_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_access_auth_sessions_revoked_at'), 'access_auth_sessions', ['revoked_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_access_auth_sessions_tenant_id'), 'access_auth_sessions', ['tenant_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_access_auth_sessions_token_hash'), 'access_auth_sessions', ['token_hash'], unique=True)
|
||||||
|
op.create_index(op.f('ix_access_auth_sessions_user_id'), 'access_auth_sessions', ['user_id'], unique=False)
|
||||||
|
op.create_table('access_user_group_memberships',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('group_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['group_id'], ['access_groups.id'], name=op.f('fk_access_user_group_memberships_group_id_access_groups'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['access_users.id'], name=op.f('fk_access_user_group_memberships_user_id_access_users'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_user_group_memberships')),
|
||||||
|
sa.UniqueConstraint('tenant_id', 'user_id', 'group_id', name='uq_user_group_memberships')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_access_user_group_memberships_group_id'), 'access_user_group_memberships', ['group_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_access_user_group_memberships_tenant_id'), 'access_user_group_memberships', ['tenant_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_access_user_group_memberships_user_id'), 'access_user_group_memberships', ['user_id'], unique=False)
|
||||||
|
op.create_table('access_user_role_assignments',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('role_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['role_id'], ['access_roles.id'], name=op.f('fk_access_user_role_assignments_role_id_access_roles'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['access_users.id'], name=op.f('fk_access_user_role_assignments_user_id_access_users'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_user_role_assignments')),
|
||||||
|
sa.UniqueConstraint('tenant_id', 'user_id', 'role_id', name='uq_user_role_assignments')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_access_user_role_assignments_role_id'), 'access_user_role_assignments', ['role_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_access_user_role_assignments_tenant_id'), 'access_user_role_assignments', ['tenant_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_access_user_role_assignments_user_id'), 'access_user_role_assignments', ['user_id'], unique=False)
|
||||||
|
op.create_table('campaigns',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('created_by_user_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('owner_user_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('owner_group_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('external_id', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('current_version_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('settings', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('mail_profile_policy', 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.ForeignKeyConstraint(['created_by_user_id'], ['access_users.id'], name=op.f('fk_campaigns_created_by_user_id_access_users'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['owner_group_id'], ['access_groups.id'], name=op.f('fk_campaigns_owner_group_id_access_groups'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['owner_user_id'], ['access_users.id'], name=op.f('fk_campaigns_owner_user_id_access_users'), ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_campaigns')),
|
||||||
|
sa.UniqueConstraint('tenant_id', 'external_id', name='uq_campaigns_tenant_external_id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_campaigns_created_by_user_id'), 'campaigns', ['created_by_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaigns_external_id'), 'campaigns', ['external_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaigns_owner_group_id'), 'campaigns', ['owner_group_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaigns_owner_user_id'), 'campaigns', ['owner_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaigns_status'), 'campaigns', ['status'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaigns_tenant_id'), 'campaigns', ['tenant_id'], unique=False)
|
||||||
|
op.create_table('file_assets',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('owner_type', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('owner_user_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('owner_group_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('current_version_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('display_path', sa.String(length=1000), nullable=False),
|
||||||
|
sa.Column('filename', sa.String(length=500), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_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(['created_by_user_id'], ['access_users.id'], name=op.f('fk_file_assets_created_by_user_id_access_users'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['owner_group_id'], ['access_groups.id'], name=op.f('fk_file_assets_owner_group_id_access_groups'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['owner_user_id'], ['access_users.id'], name=op.f('fk_file_assets_owner_user_id_access_users'), ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_file_assets'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_file_assets_created_by_user_id'), 'file_assets', ['created_by_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_assets_current_version_id'), 'file_assets', ['current_version_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_assets_deleted_at'), 'file_assets', ['deleted_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_assets_display_path'), 'file_assets', ['display_path'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_assets_filename'), 'file_assets', ['filename'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_assets_owner_group_id'), 'file_assets', ['owner_group_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_assets_owner_type'), 'file_assets', ['owner_type'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_assets_owner_user_id'), 'file_assets', ['owner_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_assets_tenant_id'), 'file_assets', ['tenant_id'], unique=False)
|
||||||
|
op.create_table('file_folders',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('owner_type', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('owner_user_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('owner_group_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('path', sa.String(length=1000), nullable=False),
|
||||||
|
sa.Column('created_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(['created_by_user_id'], ['access_users.id'], name=op.f('fk_file_folders_created_by_user_id_access_users'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['owner_group_id'], ['access_groups.id'], name=op.f('fk_file_folders_owner_group_id_access_groups'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['owner_user_id'], ['access_users.id'], name=op.f('fk_file_folders_owner_user_id_access_users'), ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_file_folders'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_file_folders_created_by_user_id'), 'file_folders', ['created_by_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_folders_deleted_at'), 'file_folders', ['deleted_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_folders_owner_group_id'), 'file_folders', ['owner_group_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_folders_owner_type'), 'file_folders', ['owner_type'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_folders_owner_user_id'), 'file_folders', ['owner_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_folders_path'), 'file_folders', ['path'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_folders_tenant_id'), 'file_folders', ['tenant_id'], unique=False)
|
||||||
|
op.create_index('uq_file_folders_active_group_path', 'file_folders', ['tenant_id', 'owner_group_id', 'path'], unique=True, sqlite_where=sa.text("owner_type = 'group' AND deleted_at IS NULL"), postgresql_where=sa.text("owner_type = 'group' AND deleted_at IS NULL"))
|
||||||
|
op.create_index('uq_file_folders_active_user_path', 'file_folders', ['tenant_id', 'owner_user_id', 'path'], unique=True, sqlite_where=sa.text("owner_type = 'user' AND deleted_at IS NULL"), postgresql_where=sa.text("owner_type = 'user' AND deleted_at IS NULL"))
|
||||||
|
op.create_table('mail_server_profiles',
|
||||||
|
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=36), nullable=True),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('slug', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('smtp_config', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('smtp_username', sa.String(length=320), nullable=True),
|
||||||
|
sa.Column('smtp_password_encrypted', sa.Text(), nullable=True),
|
||||||
|
sa.Column('imap_config', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('imap_username', sa.String(length=320), nullable=True),
|
||||||
|
sa.Column('imap_password_encrypted', sa.Text(), nullable=True),
|
||||||
|
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('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['created_by_user_id'], ['access_users.id'], name=op.f('fk_mail_server_profiles_created_by_user_id_access_users'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['updated_by_user_id'], ['access_users.id'], name=op.f('fk_mail_server_profiles_updated_by_user_id_access_users'), ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_mail_server_profiles')),
|
||||||
|
sa.UniqueConstraint('tenant_id', 'slug', name='uq_mail_server_profiles_tenant_slug')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_mail_server_profiles_created_by_user_id'), 'mail_server_profiles', ['created_by_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_mail_server_profiles_is_active'), 'mail_server_profiles', ['is_active'], unique=False)
|
||||||
|
op.create_index('ix_mail_server_profiles_scope', 'mail_server_profiles', ['scope_type', 'scope_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_mail_server_profiles_scope_id'), 'mail_server_profiles', ['scope_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_mail_server_profiles_scope_type'), 'mail_server_profiles', ['scope_type'], unique=False)
|
||||||
|
op.create_index(op.f('ix_mail_server_profiles_tenant_id'), 'mail_server_profiles', ['tenant_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_mail_server_profiles_updated_by_user_id'), 'mail_server_profiles', ['updated_by_user_id'], unique=False)
|
||||||
|
op.create_table('attachment_instances',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('owner_user_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('campaign_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('blob_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('logical_name', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('filename', sa.String(length=500), nullable=False),
|
||||||
|
sa.Column('tags', sa.JSON(), nullable=False),
|
||||||
|
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(['blob_id'], ['attachment_blobs.id'], name=op.f('fk_attachment_instances_blob_id_attachment_blobs'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], name=op.f('fk_attachment_instances_campaign_id_campaigns'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['owner_user_id'], ['access_users.id'], name=op.f('fk_attachment_instances_owner_user_id_access_users'), ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_attachment_instances'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_attachment_instances_blob_id'), 'attachment_instances', ['blob_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_attachment_instances_campaign_id'), 'attachment_instances', ['campaign_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_attachment_instances_owner_user_id'), 'attachment_instances', ['owner_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_attachment_instances_tenant_id'), 'attachment_instances', ['tenant_id'], unique=False)
|
||||||
|
op.create_table('audit_log',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('scope', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('user_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('api_key_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('action', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('object_type', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('object_id', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('details', 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(['api_key_id'], ['access_api_keys.id'], name=op.f('fk_audit_log_api_key_id_access_api_keys'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['access_users.id'], name=op.f('fk_audit_log_user_id_access_users'), ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_audit_log'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_audit_log_action'), 'audit_log', ['action'], unique=False)
|
||||||
|
op.create_index(op.f('ix_audit_log_api_key_id'), 'audit_log', ['api_key_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_audit_log_object_id'), 'audit_log', ['object_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_audit_log_object_type'), 'audit_log', ['object_type'], unique=False)
|
||||||
|
op.create_index(op.f('ix_audit_log_scope'), 'audit_log', ['scope'], unique=False)
|
||||||
|
op.create_index('ix_audit_log_scope_created_at', 'audit_log', ['scope', 'created_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_audit_log_tenant_id'), 'audit_log', ['tenant_id'], unique=False)
|
||||||
|
op.create_index('ix_audit_log_tenant_scope_created_at', 'audit_log', ['tenant_id', 'scope', 'created_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_audit_log_user_id'), 'audit_log', ['user_id'], unique=False)
|
||||||
|
op.create_table('campaign_shares',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('campaign_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('target_type', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('target_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('permission', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('created_by_user_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], name=op.f('fk_campaign_shares_campaign_id_campaigns'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['created_by_user_id'], ['access_users.id'], name=op.f('fk_campaign_shares_created_by_user_id_access_users'), ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_campaign_shares')),
|
||||||
|
sa.UniqueConstraint('campaign_id', 'target_type', 'target_id', name='uq_campaign_share_target')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_campaign_shares_campaign_id'), 'campaign_shares', ['campaign_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_shares_created_by_user_id'), 'campaign_shares', ['created_by_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_shares_revoked_at'), 'campaign_shares', ['revoked_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_shares_target_id'), 'campaign_shares', ['target_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_shares_target_type'), 'campaign_shares', ['target_type'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_shares_tenant_id'), 'campaign_shares', ['tenant_id'], unique=False)
|
||||||
|
op.create_table('campaign_versions',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('campaign_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('version_number', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('raw_json', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('schema_version', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('source_filename', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('source_base_path', sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column('workflow_state', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('current_flow', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('current_step', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('is_complete', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('editor_state', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('autosaved_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('published_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('locked_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('locked_by_user_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('user_lock_state', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('user_locked_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('user_locked_by_user_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('validation_summary', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('build_summary', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('execution_snapshot', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('execution_snapshot_hash', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('execution_snapshot_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], name=op.f('fk_campaign_versions_campaign_id_campaigns'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['locked_by_user_id'], ['access_users.id'], name=op.f('fk_campaign_versions_locked_by_user_id_access_users'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['user_locked_by_user_id'], ['access_users.id'], name=op.f('fk_campaign_versions_user_locked_by_user_id_access_users'), ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_campaign_versions')),
|
||||||
|
sa.UniqueConstraint('campaign_id', 'version_number', name='uq_campaign_versions_campaign_number')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_campaign_versions_campaign_id'), 'campaign_versions', ['campaign_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_versions_current_flow'), 'campaign_versions', ['current_flow'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_versions_execution_snapshot_hash'), 'campaign_versions', ['execution_snapshot_hash'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_versions_locked_by_user_id'), 'campaign_versions', ['locked_by_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_versions_user_lock_state'), 'campaign_versions', ['user_lock_state'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_versions_user_locked_by_user_id'), 'campaign_versions', ['user_locked_by_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_versions_workflow_state'), 'campaign_versions', ['workflow_state'], unique=False)
|
||||||
|
op.create_table('file_shares',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('file_asset_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('target_type', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('target_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('permission', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('created_by_user_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['created_by_user_id'], ['access_users.id'], name=op.f('fk_file_shares_created_by_user_id_access_users'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['file_asset_id'], ['file_assets.id'], name=op.f('fk_file_shares_file_asset_id_file_assets'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_file_shares')),
|
||||||
|
sa.UniqueConstraint('file_asset_id', 'target_type', 'target_id', 'revoked_at', name='uq_file_shares_active_target')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_file_shares_created_by_user_id'), 'file_shares', ['created_by_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_shares_file_asset_id'), 'file_shares', ['file_asset_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_shares_revoked_at'), 'file_shares', ['revoked_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_shares_target_id'), 'file_shares', ['target_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_shares_target_type'), 'file_shares', ['target_type'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_shares_tenant_id'), 'file_shares', ['tenant_id'], unique=False)
|
||||||
|
op.create_table('file_versions',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('file_asset_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('blob_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('version_number', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('filename_at_upload', sa.String(length=500), nullable=False),
|
||||||
|
sa.Column('display_path_at_upload', sa.String(length=1000), nullable=False),
|
||||||
|
sa.Column('content_type', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('size_bytes', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('checksum_sha256', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('created_by_user_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['blob_id'], ['file_blobs.id'], name=op.f('fk_file_versions_blob_id_file_blobs'), ondelete='RESTRICT'),
|
||||||
|
sa.ForeignKeyConstraint(['created_by_user_id'], ['access_users.id'], name=op.f('fk_file_versions_created_by_user_id_access_users'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['file_asset_id'], ['file_assets.id'], name=op.f('fk_file_versions_file_asset_id_file_assets'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_file_versions')),
|
||||||
|
sa.UniqueConstraint('file_asset_id', 'version_number', name='uq_file_versions_asset_number')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_file_versions_blob_id'), 'file_versions', ['blob_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_versions_checksum_sha256'), 'file_versions', ['checksum_sha256'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_versions_created_by_user_id'), 'file_versions', ['created_by_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_versions_file_asset_id'), 'file_versions', ['file_asset_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_versions_tenant_id'), 'file_versions', ['tenant_id'], unique=False)
|
||||||
|
op.create_table('campaign_jobs',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('campaign_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('campaign_version_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('entry_index', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('entry_id', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('recipient_email', sa.String(length=320), nullable=True),
|
||||||
|
sa.Column('subject', sa.String(length=998), nullable=True),
|
||||||
|
sa.Column('message_id_header', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('eml_storage_key', sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column('eml_local_path', sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column('eml_size_bytes', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('eml_sha256', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('build_status', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('validation_status', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('queue_status', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('send_status', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('imap_status', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('attempt_count', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('last_error', sa.Text(), nullable=True),
|
||||||
|
sa.Column('queued_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('claimed_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('claim_token', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('smtp_started_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('outcome_unknown_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('sent_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('resolved_recipients', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('resolved_attachments', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('issues_snapshot', 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.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], name=op.f('fk_campaign_jobs_campaign_id_campaigns'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['campaign_version_id'], ['campaign_versions.id'], name=op.f('fk_campaign_jobs_campaign_version_id_campaign_versions'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_campaign_jobs')),
|
||||||
|
sa.UniqueConstraint('campaign_version_id', 'entry_index', name='uq_campaign_jobs_version_entry')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_campaign_jobs_build_status'), 'campaign_jobs', ['build_status'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_jobs_campaign_id'), 'campaign_jobs', ['campaign_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_jobs_campaign_version_id'), 'campaign_jobs', ['campaign_version_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_jobs_claim_token'), 'campaign_jobs', ['claim_token'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_jobs_eml_sha256'), 'campaign_jobs', ['eml_sha256'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_jobs_entry_id'), 'campaign_jobs', ['entry_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_jobs_imap_status'), 'campaign_jobs', ['imap_status'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_jobs_queue_status'), 'campaign_jobs', ['queue_status'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_jobs_recipient_email'), 'campaign_jobs', ['recipient_email'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_jobs_send_status'), 'campaign_jobs', ['send_status'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_jobs_tenant_id'), 'campaign_jobs', ['tenant_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_jobs_validation_status'), 'campaign_jobs', ['validation_status'], unique=False)
|
||||||
|
op.create_table('campaign_attachment_uses',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('campaign_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('campaign_version_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('campaign_job_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('entry_index', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('entry_id', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('file_asset_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('file_version_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('file_blob_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('filename_used', sa.String(length=500), nullable=False),
|
||||||
|
sa.Column('checksum_sha256', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('size_bytes', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('content_type', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('use_stage', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], name=op.f('fk_campaign_attachment_uses_campaign_id_campaigns'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['campaign_job_id'], ['campaign_jobs.id'], name=op.f('fk_campaign_attachment_uses_campaign_job_id_campaign_jobs'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['campaign_version_id'], ['campaign_versions.id'], name=op.f('fk_campaign_attachment_uses_campaign_version_id_campaign_versions'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['file_asset_id'], ['file_assets.id'], name=op.f('fk_campaign_attachment_uses_file_asset_id_file_assets'), ondelete='RESTRICT'),
|
||||||
|
sa.ForeignKeyConstraint(['file_blob_id'], ['file_blobs.id'], name=op.f('fk_campaign_attachment_uses_file_blob_id_file_blobs'), ondelete='RESTRICT'),
|
||||||
|
sa.ForeignKeyConstraint(['file_version_id'], ['file_versions.id'], name=op.f('fk_campaign_attachment_uses_file_version_id_file_versions'), ondelete='RESTRICT'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_campaign_attachment_uses')),
|
||||||
|
sa.UniqueConstraint('campaign_job_id', 'file_version_id', 'filename_used', 'use_stage', name='uq_campaign_attachment_uses_job_file_stage')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_campaign_attachment_uses_campaign_id'), 'campaign_attachment_uses', ['campaign_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_attachment_uses_campaign_job_id'), 'campaign_attachment_uses', ['campaign_job_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_attachment_uses_campaign_version_id'), 'campaign_attachment_uses', ['campaign_version_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_attachment_uses_entry_id'), 'campaign_attachment_uses', ['entry_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_attachment_uses_file_asset_id'), 'campaign_attachment_uses', ['file_asset_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_attachment_uses_file_blob_id'), 'campaign_attachment_uses', ['file_blob_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_attachment_uses_file_version_id'), 'campaign_attachment_uses', ['file_version_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_attachment_uses_tenant_id'), 'campaign_attachment_uses', ['tenant_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_attachment_uses_use_stage'), 'campaign_attachment_uses', ['use_stage'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_attachment_uses_used_at'), 'campaign_attachment_uses', ['used_at'], unique=False)
|
||||||
|
op.create_table('campaign_issues',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('campaign_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('campaign_version_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('job_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('severity', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('code', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('message', sa.Text(), nullable=False),
|
||||||
|
sa.Column('source', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('behavior', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], name=op.f('fk_campaign_issues_campaign_id_campaigns'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['campaign_version_id'], ['campaign_versions.id'], name=op.f('fk_campaign_issues_campaign_version_id_campaign_versions'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['job_id'], ['campaign_jobs.id'], name=op.f('fk_campaign_issues_job_id_campaign_jobs'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_campaign_issues'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_campaign_issues_campaign_id'), 'campaign_issues', ['campaign_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_issues_campaign_version_id'), 'campaign_issues', ['campaign_version_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_issues_code'), 'campaign_issues', ['code'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_issues_job_id'), 'campaign_issues', ['job_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_issues_severity'), 'campaign_issues', ['severity'], unique=False)
|
||||||
|
op.create_index(op.f('ix_campaign_issues_tenant_id'), 'campaign_issues', ['tenant_id'], unique=False)
|
||||||
|
op.create_table('imap_append_attempts',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('job_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('attempt_number', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('folder', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('error_message', 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.ForeignKeyConstraint(['job_id'], ['campaign_jobs.id'], name=op.f('fk_imap_append_attempts_job_id_campaign_jobs'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_imap_append_attempts'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_imap_append_attempts_job_id'), 'imap_append_attempts', ['job_id'], unique=False)
|
||||||
|
op.create_table('send_attempts',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('job_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('attempt_number', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('claim_token', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('smtp_status_code', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('smtp_response', sa.Text(), nullable=True),
|
||||||
|
sa.Column('error_type', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('error_message', sa.Text(), nullable=True),
|
||||||
|
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['job_id'], ['campaign_jobs.id'], name=op.f('fk_send_attempts_job_id_campaign_jobs'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_send_attempts'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_send_attempts_claim_token'), 'send_attempts', ['claim_token'], unique=False)
|
||||||
|
op.create_index(op.f('ix_send_attempts_job_id'), 'send_attempts', ['job_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_send_attempts_status'), 'send_attempts', ['status'], unique=False)
|
||||||
|
_seed_core_defaults()
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('send_attempts')
|
||||||
|
op.drop_table('imap_append_attempts')
|
||||||
|
op.drop_table('campaign_issues')
|
||||||
|
op.drop_table('campaign_attachment_uses')
|
||||||
|
op.drop_table('campaign_jobs')
|
||||||
|
op.drop_table('file_versions')
|
||||||
|
op.drop_table('file_shares')
|
||||||
|
op.drop_table('campaign_versions')
|
||||||
|
op.drop_table('campaign_shares')
|
||||||
|
op.drop_table('audit_log')
|
||||||
|
op.drop_table('attachment_instances')
|
||||||
|
op.drop_table('mail_server_profiles')
|
||||||
|
op.drop_table('file_folders')
|
||||||
|
op.drop_table('file_assets')
|
||||||
|
op.drop_table('campaigns')
|
||||||
|
op.drop_table('access_user_role_assignments')
|
||||||
|
op.drop_table('access_user_group_memberships')
|
||||||
|
op.drop_table('access_auth_sessions')
|
||||||
|
op.drop_table('access_api_keys')
|
||||||
|
op.drop_table('admin_governance_template_assignments')
|
||||||
|
op.drop_table('access_users')
|
||||||
|
op.drop_table('access_system_role_assignments')
|
||||||
|
op.drop_table('access_group_role_assignments')
|
||||||
|
op.drop_table('file_blobs')
|
||||||
|
op.drop_table('core_system_settings')
|
||||||
|
op.drop_table('core_change_sequence_retention_floor')
|
||||||
|
op.drop_table('core_change_sequence')
|
||||||
|
op.drop_table('audit_outbox_events')
|
||||||
|
op.drop_table('attachment_blobs')
|
||||||
|
op.drop_table('admin_governance_templates')
|
||||||
|
op.drop_table('access_roles')
|
||||||
|
op.drop_table('access_groups')
|
||||||
|
op.drop_table('access_accounts')
|
||||||
|
op.drop_table('core_scopes')
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
# GovOPlaN RBAC And Resource-Access Model
|
||||||
|
|
||||||
|
**Updated:** 2026-07-11
|
||||||
|
|
||||||
|
## Authorization Equation
|
||||||
|
|
||||||
|
An operation is permitted only when every applicable layer allows it:
|
||||||
|
|
||||||
|
```text
|
||||||
|
effective role/API-key capability
|
||||||
|
AND resource ownership/share access
|
||||||
|
AND workflow state
|
||||||
|
AND active governance/policy constraints
|
||||||
|
```
|
||||||
|
|
||||||
|
RBAC answers what an actor may do. ACLs answer which resource the actor may do
|
||||||
|
it to. Workflow state and policy decide whether the operation is currently
|
||||||
|
valid.
|
||||||
|
|
||||||
|
## Identity And Scope
|
||||||
|
|
||||||
|
```text
|
||||||
|
Account global login identity
|
||||||
|
+- User membership tenant-local identity
|
||||||
|
+- direct tenant roles
|
||||||
|
+- active group memberships
|
||||||
|
| +- inherited tenant roles
|
||||||
|
+- tenant-local API keys
|
||||||
|
|
||||||
|
Account
|
||||||
|
+- direct system-role assignments
|
||||||
|
```
|
||||||
|
|
||||||
|
A browser session has one active tenant membership. System privileges do not
|
||||||
|
silently grant tenant data access. API keys remain tenant-local and receive the
|
||||||
|
intersection of their configured scopes and their owner's live tenant scopes on
|
||||||
|
every request.
|
||||||
|
|
||||||
|
## Wildcards
|
||||||
|
|
||||||
|
```text
|
||||||
|
tenant:* every canonical tenant permission
|
||||||
|
system:* every canonical system permission
|
||||||
|
* legacy alias interpreted as tenant:* only
|
||||||
|
```
|
||||||
|
|
||||||
|
Tenant wildcards never grant system permissions.
|
||||||
|
|
||||||
|
## Canonical Tenant Permissions
|
||||||
|
|
||||||
|
Campaigns:
|
||||||
|
|
||||||
|
```text
|
||||||
|
campaign:read
|
||||||
|
campaign:create
|
||||||
|
campaign:update
|
||||||
|
campaign:copy
|
||||||
|
campaign:archive
|
||||||
|
campaign:delete
|
||||||
|
campaign:share
|
||||||
|
campaign:validate
|
||||||
|
campaign:build
|
||||||
|
campaign:review
|
||||||
|
campaign:send_test
|
||||||
|
campaign:queue
|
||||||
|
campaign:control
|
||||||
|
campaign:send
|
||||||
|
campaign:retry
|
||||||
|
campaign:reconcile
|
||||||
|
```
|
||||||
|
|
||||||
|
Recipients:
|
||||||
|
|
||||||
|
```text
|
||||||
|
recipients:read
|
||||||
|
recipients:write
|
||||||
|
recipients:import
|
||||||
|
recipients:export
|
||||||
|
```
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
```text
|
||||||
|
files:read
|
||||||
|
files:download
|
||||||
|
files:upload
|
||||||
|
files:organize
|
||||||
|
files:share
|
||||||
|
files:delete
|
||||||
|
files:admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Reports and audit:
|
||||||
|
|
||||||
|
```text
|
||||||
|
reports:read
|
||||||
|
reports:export
|
||||||
|
reports:send
|
||||||
|
audit:read
|
||||||
|
```
|
||||||
|
|
||||||
|
Mail servers:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mail_servers:read
|
||||||
|
mail_servers:use
|
||||||
|
mail_servers:test
|
||||||
|
mail_servers:write
|
||||||
|
mail_servers:manage_credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
Tenant administration:
|
||||||
|
|
||||||
|
```text
|
||||||
|
admin:users:read
|
||||||
|
admin:users:create
|
||||||
|
admin:users:update
|
||||||
|
admin:users:suspend
|
||||||
|
|
||||||
|
admin:groups:read
|
||||||
|
admin:groups:write
|
||||||
|
admin:groups:manage_members
|
||||||
|
|
||||||
|
admin:roles:read
|
||||||
|
admin:roles:write
|
||||||
|
admin:roles:assign
|
||||||
|
|
||||||
|
admin:api_keys:read
|
||||||
|
admin:api_keys:create
|
||||||
|
admin:api_keys:revoke
|
||||||
|
|
||||||
|
admin:settings:read
|
||||||
|
admin:settings:write
|
||||||
|
admin:policies:read
|
||||||
|
admin:policies:write
|
||||||
|
```
|
||||||
|
|
||||||
|
## Canonical System Permissions
|
||||||
|
|
||||||
|
```text
|
||||||
|
system:tenants:read
|
||||||
|
system:tenants:create
|
||||||
|
system:tenants:update
|
||||||
|
system:tenants:suspend
|
||||||
|
|
||||||
|
system:accounts:read
|
||||||
|
system:accounts:create
|
||||||
|
system:accounts:update
|
||||||
|
system:accounts:suspend
|
||||||
|
|
||||||
|
system:roles:read
|
||||||
|
system:roles:write
|
||||||
|
system:roles:assign
|
||||||
|
|
||||||
|
system:access:read
|
||||||
|
system:access:assign
|
||||||
|
|
||||||
|
system:audit:read
|
||||||
|
system:settings:read
|
||||||
|
system:settings:write
|
||||||
|
system:governance:read
|
||||||
|
system:governance:write
|
||||||
|
```
|
||||||
|
|
||||||
|
`system:access:*` remains a read/assignment boundary for cross-tenant and
|
||||||
|
system access handling. It is not a separate primary UI area.
|
||||||
|
|
||||||
|
## Default Tenant Roles
|
||||||
|
|
||||||
|
- **Owner:** `tenant:*`. At least one active operational owner must remain.
|
||||||
|
- **Tenant administrator:** settings, policies, users, groups, roles, API keys,
|
||||||
|
and read access to campaigns/files/reports/audit. Real delivery remains
|
||||||
|
separately delegable.
|
||||||
|
- **Administrator:** all tenant permissions for upgraded installations.
|
||||||
|
- **Access administrator:** membership and assignment management within
|
||||||
|
delegation limits.
|
||||||
|
- **Campaign manager:** prepare, validate, and build campaigns; no review
|
||||||
|
approval or real delivery by default.
|
||||||
|
- **Reviewer:** inspect and approve prepared campaign messages.
|
||||||
|
- **Sender:** mock-test, queue, control, send, retry, and reconcile prepared
|
||||||
|
campaigns; can use/test approved mail profiles.
|
||||||
|
- **File manager:** managed file operations without campaign delivery rights.
|
||||||
|
- **Viewer:** read campaigns, recipients, files, and reports.
|
||||||
|
- **Auditor:** read campaigns, recipient evidence, reports, and audit records;
|
||||||
|
export detailed evidence.
|
||||||
|
|
||||||
|
## Default System Roles
|
||||||
|
|
||||||
|
- **System owner:** `system:*`, protected. At least one active account must
|
||||||
|
retain it.
|
||||||
|
- **System administrator:** all specific system permissions, editable and not
|
||||||
|
protected.
|
||||||
|
- **System auditor:** read-only system registry/settings/governance/audit role,
|
||||||
|
editable.
|
||||||
|
|
||||||
|
## Delegation Ceiling
|
||||||
|
|
||||||
|
For role definition, assignment, and API-key creation:
|
||||||
|
|
||||||
|
```text
|
||||||
|
requested scopes subset of actor delegateable scopes
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
1. Tenant roles may contain tenant scopes only.
|
||||||
|
2. System roles may contain system scopes only.
|
||||||
|
3. Definition rights and assignment rights are separate.
|
||||||
|
4. Group definition and group membership management are separate.
|
||||||
|
5. API-key scopes are intersected with the owner's current effective scopes on
|
||||||
|
every request.
|
||||||
|
6. Suspended accounts, users, tenants, or groups stop contributing access
|
||||||
|
immediately.
|
||||||
|
7. Administrative updates are field-sensitive; a user with only status
|
||||||
|
authority cannot change role assignments.
|
||||||
|
|
||||||
|
## Campaign Ownership And ACLs
|
||||||
|
|
||||||
|
A campaign has exactly one owner:
|
||||||
|
|
||||||
|
```text
|
||||||
|
owner user OR owner group
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional active shares may target users or groups with `read` or `write`.
|
||||||
|
|
||||||
|
Resolution:
|
||||||
|
|
||||||
|
- owner user: read and write;
|
||||||
|
- member of owner group: read and write;
|
||||||
|
- explicit read share: read;
|
||||||
|
- explicit write share: read and write;
|
||||||
|
- `tenant:*`: tenant-wide ACL bypass;
|
||||||
|
- ordinary campaign permission without ownership/share: no object access.
|
||||||
|
|
||||||
|
ACLs do not add capabilities. A write share still needs the specific permission
|
||||||
|
for update, validation, review, send, report, retry, or reconciliation.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
The current file access model distinguishes tenant-level file capabilities from
|
||||||
|
space/folder/file ownership:
|
||||||
|
|
||||||
|
| Scope | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `files:read` | browse/read visible file spaces and metadata |
|
||||||
|
| `files:download` | download file content when ACL permits |
|
||||||
|
| `files:upload` | create files in writable spaces |
|
||||||
|
| `files:organize` | create folders, move files, and update metadata where ACL permits |
|
||||||
|
| `files:share` | share files/spaces according to owner and policy rules |
|
||||||
|
| `files:delete` | delete or retire files where ACL permits |
|
||||||
|
| `files:admin` | tenant-wide administration of user/group file spaces |
|
||||||
|
|
||||||
|
External file connections and spaces are additionally constrained by connector
|
||||||
|
policy and owner/group assignment in the files module.
|
||||||
|
|
||||||
|
## Resource Access Explanations
|
||||||
|
|
||||||
|
The access module exposes a diagnostic endpoint for explaining why a principal
|
||||||
|
can see or operate on a concrete resource:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/v1/admin/access/resource-explanation
|
||||||
|
```
|
||||||
|
|
||||||
|
Required query values:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `user_id` | Tenant membership to explain. The current module UIs pass the signed-in user. |
|
||||||
|
| `resource_type` | Module-owned type such as `file`, `folder`, or `campaign`. |
|
||||||
|
| `resource_id` | Stable module resource identifier. |
|
||||||
|
| `action` | Permission/action being explained, for example `files:file:read`. |
|
||||||
|
| `tenant_id` | Optional tenant override for system/admin contexts. |
|
||||||
|
|
||||||
|
The access module always contributes effective-scope provenance for the action.
|
||||||
|
Installed modules may add resource provenance by exposing a
|
||||||
|
`ResourceAccessExplanationProvider` through a capability consumed by access.
|
||||||
|
Providers should return only facts they own, using these provenance kinds:
|
||||||
|
|
||||||
|
| Kind | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `resource` | The concrete resource or an explicit not-found result. |
|
||||||
|
| `owner` | Matching user/group ownership. |
|
||||||
|
| `share` | Matching explicit user/group/tenant share. |
|
||||||
|
| `policy` | Administrative bypass or policy-derived grant. |
|
||||||
|
| `role` / `right` | Scope and role provenance from access itself. |
|
||||||
|
|
||||||
|
Files currently registers `files.access` and explains file assets plus folders.
|
||||||
|
Persisted folders use their database ID. Folder rows inferred from file paths use
|
||||||
|
a deterministic virtual ID:
|
||||||
|
|
||||||
|
```text
|
||||||
|
virtual-folder:v1:<tenant_id>:<owner_type>:<owner_id>:<base64url(normalized_path)>
|
||||||
|
```
|
||||||
|
|
||||||
|
The files provider validates virtual folder IDs before returning provenance: the
|
||||||
|
tenant and owner must match the resource ID, and at least one active file must
|
||||||
|
exist below the normalized folder path. This keeps virtual folder explanations
|
||||||
|
stable without forcing every inferred tree node to become a stored folder row.
|
||||||
|
|
||||||
|
Campaign currently registers `campaigns.access` and explains the campaign
|
||||||
|
ownership/sharing object itself. Finer-grained campaign sub-objects are tracked
|
||||||
|
separately in `govoplan-campaign#50` until their resource identifiers and access
|
||||||
|
rules are decided.
|
||||||
|
|
||||||
|
Cross-user resource explanation is a policy feature, not a module-local UI
|
||||||
|
detail. Until `govoplan-policy#6` is resolved, module UIs should default to
|
||||||
|
current-user explanation and avoid importing access-admin user-picking
|
||||||
|
components.
|
||||||
|
|
||||||
|
## Mail Servers
|
||||||
|
|
||||||
|
| Scope | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `mail_servers:read` | profile metadata and effective policy visibility |
|
||||||
|
| `mail_servers:use` | use an allowed profile for a campaign or message flow |
|
||||||
|
| `mail_servers:test` | run connectivity tests without revealing secrets |
|
||||||
|
| `mail_servers:write` | create/update profile metadata where policy allows |
|
||||||
|
| `mail_servers:manage_credentials` | create/replace SMTP/IMAP secrets or lower-level credentials where policy allows |
|
||||||
|
|
||||||
|
Reusable encrypted profiles exist. Effective usability is also constrained by
|
||||||
|
hierarchical mail-profile policy, ownership, allowed/forced profile sets,
|
||||||
|
credential inheritance mode, lower-level override switches, and allow/deny
|
||||||
|
patterns.
|
||||||
|
|
||||||
|
## Compatibility Aliases
|
||||||
|
|
||||||
|
Compatibility aliases may exist in backend code for upgraded installations, but
|
||||||
|
new UI and docs should use canonical scopes.
|
||||||
|
|
||||||
|
Current alias direction:
|
||||||
|
|
||||||
|
```text
|
||||||
|
* -> tenant:*
|
||||||
|
system:tenants:write -> create/update/suspend tenant scopes
|
||||||
|
system:access:write -> system access assignment/write scopes
|
||||||
|
```
|
||||||
|
|
||||||
|
A separate `retention:*` family is not currently canonical because retention is
|
||||||
|
managed through system settings and tenant policy scopes. Add it only if
|
||||||
|
retention operation duties need separation from general policy/settings
|
||||||
|
administration.
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# Action, Effect, And Automation Layer
|
||||||
|
|
||||||
|
GovOPlaN needs an automation layer because administrative processes will not be
|
||||||
|
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 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.
|
||||||
|
|
||||||
|
## Layer Purpose
|
||||||
|
|
||||||
|
The automation layer should provide:
|
||||||
|
|
||||||
|
- a typed action catalogue
|
||||||
|
- a typed effect catalogue
|
||||||
|
- consequence preview before execution
|
||||||
|
- policy and permission checks
|
||||||
|
- idempotent execution
|
||||||
|
- audit and provenance records
|
||||||
|
- retry, quarantine, and manual exception handling
|
||||||
|
- system-actor execution without hiding responsibility
|
||||||
|
|
||||||
|
Automation is not a shortcut around module boundaries. It is a governed caller
|
||||||
|
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:
|
||||||
|
|
||||||
|
- `action_key`
|
||||||
|
- owning module
|
||||||
|
- input schema
|
||||||
|
- actor requirements and required scopes
|
||||||
|
- required capabilities
|
||||||
|
- policy checks
|
||||||
|
- risk level
|
||||||
|
- reversibility class: reversible, compensatable, corrective-only, or
|
||||||
|
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
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- create a case from a form submission
|
||||||
|
- assign a task
|
||||||
|
- generate a document from a template
|
||||||
|
- send a postbox message
|
||||||
|
- send an email notification
|
||||||
|
- append evidence to records
|
||||||
|
- create a payment request
|
||||||
|
- call an external connector
|
||||||
|
|
||||||
|
## Effect Definition
|
||||||
|
|
||||||
|
An `EffectDefinition` describes the expected and observed result of an action.
|
||||||
|
|
||||||
|
Recommended fields:
|
||||||
|
|
||||||
|
- `effect_key`
|
||||||
|
- affected module and resource references
|
||||||
|
- external system references where applicable
|
||||||
|
- created, changed, deleted, sent, notified, locked, or retained markers
|
||||||
|
- visibility and privacy classification
|
||||||
|
- audit event references
|
||||||
|
- rollback or compensation hints
|
||||||
|
- operator-facing explanation
|
||||||
|
|
||||||
|
Effects should be recorded even when execution fails partially. This makes
|
||||||
|
manual recovery and audit review possible.
|
||||||
|
|
||||||
|
## Execution Model
|
||||||
|
|
||||||
|
The runner should execute an action plan as follows:
|
||||||
|
|
||||||
|
1. Resolve actor context: human, delegated actor, or system actor.
|
||||||
|
2. Validate input schema.
|
||||||
|
3. Resolve required module capabilities.
|
||||||
|
4. Run permission and policy checks.
|
||||||
|
5. Generate a consequence preview.
|
||||||
|
6. Reserve or verify the idempotency key.
|
||||||
|
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:
|
||||||
|
|
||||||
|
- `blocked`: policy, permission, missing capability, or invalid input prevents
|
||||||
|
execution.
|
||||||
|
- `retryable`: transient transport, timeout, rate-limit, or lock conflict.
|
||||||
|
- `quarantined`: unexpected response, schema mismatch, unsafe partial result,
|
||||||
|
or unknown external state.
|
||||||
|
- `manual_required`: human decision or correction is needed.
|
||||||
|
- `compensation_required`: a later action must correct an already observed
|
||||||
|
side effect.
|
||||||
|
|
||||||
|
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-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
|
||||||
|
document generation actions, postbox owns postbox message actions, and
|
||||||
|
connectors own external handoff actions.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Codex Workflow
|
||||||
|
|
||||||
|
This project is split across the core runner and sibling module repositories. Codex works best when all active repositories are writable from the start and routine checks use targeted commands.
|
||||||
|
|
||||||
|
## Personal Codex Config
|
||||||
|
|
||||||
|
Put machine-specific access in `~/.codex/config.toml`, not in a tracked project file:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
model = "gpt-5.5"
|
||||||
|
model_reasoning_effort = "xhigh"
|
||||||
|
personality = "pragmatic"
|
||||||
|
|
||||||
|
sandbox_mode = "workspace-write"
|
||||||
|
approval_policy = "on-request"
|
||||||
|
approvals_reviewer = "user"
|
||||||
|
|
||||||
|
[sandbox_workspace_write]
|
||||||
|
writable_roots = [
|
||||||
|
"/mnt/DATA/git",
|
||||||
|
]
|
||||||
|
network_access = false
|
||||||
|
|
||||||
|
[projects."/mnt/DATA/git/govoplan-core"]
|
||||||
|
trust_level = "trusted"
|
||||||
|
|
||||||
|
[projects."/mnt/DATA/git/govoplan-access"]
|
||||||
|
trust_level = "trusted"
|
||||||
|
|
||||||
|
[projects."/mnt/DATA/git/govoplan-organizations"]
|
||||||
|
trust_level = "trusted"
|
||||||
|
|
||||||
|
[projects."/mnt/DATA/git/govoplan-identity"]
|
||||||
|
trust_level = "trusted"
|
||||||
|
|
||||||
|
[projects."/mnt/DATA/git/govoplan-mail"]
|
||||||
|
trust_level = "trusted"
|
||||||
|
|
||||||
|
[projects."/mnt/DATA/git/govoplan-files"]
|
||||||
|
trust_level = "trusted"
|
||||||
|
|
||||||
|
[projects."/mnt/DATA/git/govoplan-campaign"]
|
||||||
|
trust_level = "trusted"
|
||||||
|
```
|
||||||
|
|
||||||
|
The broad writable root reduces approval churn. The explicit project trust entries allow project-local `AGENTS.md` guidance to load for each repository.
|
||||||
|
|
||||||
|
## Repository Guidance
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Focused Verification
|
||||||
|
|
||||||
|
Use the consolidated script after changes that touch module discovery, optional integrations, shared mail components, mailbox listing, or cross-module WebUI behavior:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
tools/checks/check-focused.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
For smaller changes, prefer the narrow command named in the relevant `AGENTS.md` file. Examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan-core
|
||||||
|
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest tests.test_module_system
|
||||||
|
|
||||||
|
cd /mnt/DATA/git/govoplan-mail
|
||||||
|
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
|
||||||
|
|
||||||
|
cd /mnt/DATA/git/govoplan-core/webui
|
||||||
|
PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:module-permutations
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Discipline
|
||||||
|
|
||||||
|
- Prefer `rg`, `sed`, and targeted test commands.
|
||||||
|
- 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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
# GovOPlaN Configuration Packages
|
||||||
|
|
||||||
|
Configuration packages are reusable, versioned preconfigurations for a working
|
||||||
|
GovOPlaN capability. They sit above modules: a module installs code,
|
||||||
|
migrations, routes, permissions, and capabilities; a configuration package wires
|
||||||
|
installed modules into a concrete operational setup.
|
||||||
|
|
||||||
|
Example: an application-handling package could configure a public portal form,
|
||||||
|
a case workflow, task creation, a mail template, payment processing, access
|
||||||
|
roles, audit evidence, and the interface bindings between those modules.
|
||||||
|
|
||||||
|
The guiding reference scenario is the government-operations permit journey in
|
||||||
|
`docs/GOVOPLAN_MASTER_ROADMAP.md`: a person applies through the portal,
|
||||||
|
uploads files, receives workflow-driven messages and appointment proposals, has
|
||||||
|
a case opened, gets a permit generated from a template, and completes payment.
|
||||||
|
Configuration packages are the mechanism that should make such processes
|
||||||
|
reusable and safely importable.
|
||||||
|
|
||||||
|
This document is durable architecture context and should be mirrored to the
|
||||||
|
Gitea wiki. Active implementation should be tracked in Gitea issues.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Import a preconfiguration from a trusted catalog or uploaded package.
|
||||||
|
- Detect which modules, module versions, and capabilities are required.
|
||||||
|
- Detect which configuration fragments must be created, updated, or bound.
|
||||||
|
- Explain import problems and offer actionable resolution paths.
|
||||||
|
- Ask the operator only for data needed to make the package work.
|
||||||
|
- Export an existing working configuration as a reusable package.
|
||||||
|
- Support signed catalogs so approved configurations can be shared, adapted,
|
||||||
|
re-exported, and provided to other installations.
|
||||||
|
|
||||||
|
## Vocabulary
|
||||||
|
|
||||||
|
| Term | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| Module | Installable product code with manifest, migrations, routes, permissions, WebUI, events, and capabilities. |
|
||||||
|
| Configuration | Module-owned settings and resources that make behavior concrete: workflows, forms, templates, roles, policies, connector profiles, routing rules, and defaults. |
|
||||||
|
| Interface | A typed contract between modules: capabilities, commands, events, DTOs, data bindings, and UI extension points. |
|
||||||
|
| Data | Operator- or tenant-provided values needed for a working setup: legal text, sender addresses, account mappings, payment provider credentials, templates, defaults, and optionally reference data. |
|
||||||
|
|
||||||
|
The system should communicate these four layers explicitly:
|
||||||
|
|
||||||
|
```text
|
||||||
|
module = what can exist
|
||||||
|
configuration = what should exist here
|
||||||
|
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
|
||||||
|
configuration fragments. The package is not a database dump. It should be
|
||||||
|
declarative, idempotent, tenant-aware, and explicit about secrets and external
|
||||||
|
systems.
|
||||||
|
|
||||||
|
Required package metadata:
|
||||||
|
|
||||||
|
- stable package id, name, version, description, publisher, and license
|
||||||
|
- supported GovOPlaN core/module compatibility bounds
|
||||||
|
- required modules with version constraints
|
||||||
|
- optional modules with conditional behavior
|
||||||
|
- required capabilities, commands, event subscriptions, and UI extension points
|
||||||
|
- configuration fragments grouped by owning module
|
||||||
|
- interface bindings between fragments
|
||||||
|
- data requirements to collect from the operator
|
||||||
|
- 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;
|
||||||
|
mail imports mail templates and delivery defaults; payments imports payment
|
||||||
|
profiles; access imports groups, roles, and permission assignments.
|
||||||
|
|
||||||
|
## Module Provider Contract
|
||||||
|
|
||||||
|
Each module that wants to participate should expose a configuration-package
|
||||||
|
provider capability. The core orchestrator should not understand module internals
|
||||||
|
or write module-owned tables directly.
|
||||||
|
|
||||||
|
Baseline provider responsibilities:
|
||||||
|
|
||||||
|
- publish JSON Schema or equivalent typed schemas for importable/exportable
|
||||||
|
fragments
|
||||||
|
- publish data requirements that can be rendered by a generic wizard
|
||||||
|
- validate fragments without applying them
|
||||||
|
- report missing dependencies, missing permissions, conflicts, and unsafe
|
||||||
|
changes
|
||||||
|
- produce a dry-run plan with create, update, bind, skip, and blocked actions
|
||||||
|
- apply fragments idempotently inside module-owned boundaries
|
||||||
|
- export selected module-owned configuration into portable fragments
|
||||||
|
- redact secrets and mark secret placeholders during export
|
||||||
|
- provide post-apply health checks and operator-facing diagnostics
|
||||||
|
|
||||||
|
Provider operations should be versioned. The first stable contract can be small:
|
||||||
|
|
||||||
|
```text
|
||||||
|
describe() -> schemas, supported fragment types, exported scopes
|
||||||
|
preflight(package_fragment, context) -> diagnostics, required_data, plan
|
||||||
|
apply(package_fragment, supplied_data, context) -> result, created_refs
|
||||||
|
export(selection, context) -> fragment, data_placeholders, warnings
|
||||||
|
health(import_result, context) -> diagnostics
|
||||||
|
```
|
||||||
|
|
||||||
|
The initial core contract lives in
|
||||||
|
`govoplan_core.core.configuration_packages`. Modules register providers through
|
||||||
|
module-specific capability keys and implement the `ConfigurationProvider`
|
||||||
|
protocol. The generic `configuration.provider` key names the contract and can be
|
||||||
|
used in package requirements; concrete providers such as `access.configuration`
|
||||||
|
are resolved by the admin package wizard. The first DTO surface includes package
|
||||||
|
manifests, module/capability requirements, module-owned fragments, diagnostics,
|
||||||
|
required operator data, dry-run plan items, apply results, export selections,
|
||||||
|
and export results.
|
||||||
|
|
||||||
|
The initial implementation includes provider-neutral orchestration helpers:
|
||||||
|
|
||||||
|
- `dry_run_configuration_package(...)`
|
||||||
|
- `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. 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.
|
||||||
|
|
||||||
|
Core also defines the inverse, read-only dependency-inventory contract used
|
||||||
|
before the installer changes one of those infrastructure capabilities. An
|
||||||
|
enabled module registers
|
||||||
|
`infrastructure.dependency_inventory.<module_id>` and returns bounded, stable
|
||||||
|
references to its persisted configuration or data, a lifecycle state, scope,
|
||||||
|
numeric metrics, and a required operator action. Providers must not return
|
||||||
|
secrets or use this read to migrate state. The Core collector validates provider
|
||||||
|
identity and capability coverage, orders records deterministically, and marks
|
||||||
|
the complete inventory failed when any provider raises or violates the
|
||||||
|
contract. Ops is the authorized projection boundary; the installer remains the
|
||||||
|
consumer and must match installation id, freshness, completion and impacted
|
||||||
|
capability coverage before apply.
|
||||||
|
|
||||||
|
The admin wizard backend starts with these routes:
|
||||||
|
|
||||||
|
- `GET /api/v1/admin/configuration-packages/catalog`
|
||||||
|
- `POST /api/v1/admin/configuration-packages/dry-run`
|
||||||
|
- `POST /api/v1/admin/configuration-packages/apply`
|
||||||
|
- `POST /api/v1/admin/configuration-packages/export`
|
||||||
|
|
||||||
|
## Import Flow
|
||||||
|
|
||||||
|
1. Select a package from a trusted catalog or upload a package file.
|
||||||
|
2. Verify signature, channel, publisher trust, package compatibility, and schema.
|
||||||
|
3. Resolve required modules against installed modules and the module package
|
||||||
|
catalog.
|
||||||
|
4. Produce a dependency plan for modules that must be installed or upgraded.
|
||||||
|
5. Ask the operator for required data using a focused wizard.
|
||||||
|
6. Run dry-run preflight across all participating module providers.
|
||||||
|
7. Show problems grouped by severity, owner module, and resolution.
|
||||||
|
8. Apply module/package changes in a dependency-safe order.
|
||||||
|
9. Run post-import health checks and show the final working status.
|
||||||
|
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
|
||||||
|
package and installed modules require them.
|
||||||
|
|
||||||
|
## Problem Model
|
||||||
|
|
||||||
|
Import diagnostics should be structured and actionable, not free-text logs.
|
||||||
|
Every problem should include severity, owner, affected object, explanation, and
|
||||||
|
at least one suggested resolution when the system can infer it.
|
||||||
|
|
||||||
|
Common diagnostic types:
|
||||||
|
|
||||||
|
- missing module or incompatible module version
|
||||||
|
- missing capability or disabled optional integration
|
||||||
|
- missing operator-supplied data
|
||||||
|
- missing permission or insufficient administrator scope
|
||||||
|
- unresolved interface binding between modules
|
||||||
|
- conflicting existing configuration
|
||||||
|
- external service not reachable or credential test failed
|
||||||
|
- policy or tenant-governance violation
|
||||||
|
- unsafe overwrite, downgrade, or destructive change
|
||||||
|
- schema validation failure
|
||||||
|
- post-import health-check failure
|
||||||
|
|
||||||
|
Resolution actions can include install module, upgrade module, enable
|
||||||
|
integration, provide value, choose existing object, rename imported object,
|
||||||
|
skip optional fragment, replace existing configuration, or cancel import.
|
||||||
|
|
||||||
|
## Export Flow
|
||||||
|
|
||||||
|
Export should be possible from a working tenant or system configuration, but it
|
||||||
|
must be intentional about data boundaries.
|
||||||
|
|
||||||
|
1. Select export scope: system, tenant, module set, workflow, form, case type,
|
||||||
|
portal flow, or another domain object.
|
||||||
|
2. Ask participating modules to export owned fragments and data placeholders.
|
||||||
|
3. Classify exported material as configuration, reference data, sample data,
|
||||||
|
secrets, or deployment-local data.
|
||||||
|
4. Redact secrets by default and replace them with data requirements.
|
||||||
|
5. Let the operator choose whether to include reference/sample data.
|
||||||
|
6. Validate the assembled package by running the same preflight path against a
|
||||||
|
clean target context where possible.
|
||||||
|
7. Sign the package or produce an unsigned development package.
|
||||||
|
8. Optionally publish the package metadata to a configuration catalog.
|
||||||
|
|
||||||
|
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:
|
||||||
|
a file-backed or remotely fetched JSON catalog with Ed25519 signatures, channel
|
||||||
|
gating, trusted key ids, and operator-controlled trust policy.
|
||||||
|
|
||||||
|
The initial catalog validator mirrors the module catalog environment model with
|
||||||
|
configuration-specific names:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG=/srv/govoplan/configuration-catalogs/stable.json
|
||||||
|
GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_URL=https://govoplan.example/configuration-catalogs/stable.json
|
||||||
|
GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_CACHE=/srv/govoplan/runtime/configuration-catalog-cache/stable.json
|
||||||
|
GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_REQUIRE_SIGNATURE=true
|
||||||
|
GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_APPROVED_CHANNELS=stable,lts
|
||||||
|
GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_TRUSTED_KEYS_FILE=/srv/govoplan/trust/configuration-catalog-keyring.json
|
||||||
|
GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_SEQUENCE_STATE=/srv/govoplan/runtime/configuration-catalog-sequences.json
|
||||||
|
GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_ENFORCE_SEQUENCE=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Catalog entries should include:
|
||||||
|
|
||||||
|
- package id, version, name, description, publisher, tags, and channel
|
||||||
|
- package artifact URL or repository ref
|
||||||
|
- signature metadata for the package artifact
|
||||||
|
- required modules and version ranges for quick compatibility display
|
||||||
|
- package category such as workflow, portal, case type, governance, connector,
|
||||||
|
report, or tenant baseline
|
||||||
|
- maturity flags such as official, verified, example, local, deprecated
|
||||||
|
|
||||||
|
The catalog verifies that a package is approved to view and import. The package
|
||||||
|
itself must still be signed and validated before use.
|
||||||
|
|
||||||
|
## Example Package
|
||||||
|
|
||||||
|
Application handling through a public portal:
|
||||||
|
|
||||||
|
Required modules:
|
||||||
|
|
||||||
|
- `portal` for the public user-facing entry point
|
||||||
|
- `forms` for the application form and validation
|
||||||
|
- `cases` for the case record and case type
|
||||||
|
- `workflow` for status transitions and automation
|
||||||
|
- `tasks` for internal task creation
|
||||||
|
- `templates` for mail/template rendering
|
||||||
|
- `mail` for delivery profile and outbound message sending
|
||||||
|
- `payments` for provider configuration and payment capture
|
||||||
|
- `access` for roles, groups, and permissions
|
||||||
|
- `audit` for import, case, mail, and payment evidence
|
||||||
|
|
||||||
|
Required configuration:
|
||||||
|
|
||||||
|
- portal route and access policy
|
||||||
|
- form schema, validation rules, confirmation texts, and attachments
|
||||||
|
- case type, fields, lifecycle states, and retention classification
|
||||||
|
- workflow steps, transitions, assignees, and completion triggers
|
||||||
|
- task template and queue/group assignment
|
||||||
|
- mail template and delivery rule
|
||||||
|
- payment product, amount rules, provider profile, and webhook binding
|
||||||
|
- access roles/groups for clerks, reviewers, supervisors, and operators
|
||||||
|
- audit event categories and evidence retention defaults
|
||||||
|
|
||||||
|
Required operator data:
|
||||||
|
|
||||||
|
- tenant or organizational unit
|
||||||
|
- service name and public contact details
|
||||||
|
- portal URL/path and legal imprint/privacy text
|
||||||
|
- mail sender profile and reply-to mailbox
|
||||||
|
- payment provider credentials or test-mode selection
|
||||||
|
- responsible groups or users for task assignment
|
||||||
|
- escalation contacts and deadlines
|
||||||
|
- template wording and localized text overrides
|
||||||
|
|
||||||
|
Potential problems:
|
||||||
|
|
||||||
|
- `payments` is missing or the provider capability is unavailable
|
||||||
|
- mail transport test fails for the selected sender profile
|
||||||
|
- imported role names conflict with existing tenant roles
|
||||||
|
- workflow references a task queue that does not exist
|
||||||
|
- public portal base URL is not configured
|
||||||
|
- required privacy/legal text is empty
|
||||||
|
- webhook endpoint cannot be validated from the payment provider
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
Core should own:
|
||||||
|
|
||||||
|
- package and catalog signature validation
|
||||||
|
- dependency resolution against installed modules and module catalogs
|
||||||
|
- orchestration of provider preflight/apply/export operations
|
||||||
|
- generic import/export APIs and audit envelope
|
||||||
|
- generic wizard shell and problem display components
|
||||||
|
|
||||||
|
Modules should own:
|
||||||
|
|
||||||
|
- schemas and semantics for their own fragments
|
||||||
|
- module-specific validation, apply, export, and health checks
|
||||||
|
- module-specific UI capabilities only when generic generated controls are not
|
||||||
|
sufficient
|
||||||
|
- redaction and classification of module-owned secrets or sensitive data
|
||||||
|
|
||||||
|
Access should own configuration fragments for users, groups, roles,
|
||||||
|
permissions, API keys, and principal mappings. It should not own workflow, mail,
|
||||||
|
payment, form, or portal semantics.
|
||||||
|
|
||||||
|
Admin should expose the operator-facing catalog, import, export, and history
|
||||||
|
screens through admin route contributions. The UI should keep the conceptual
|
||||||
|
layers visible: modules, configuration, interfaces, and data.
|
||||||
|
|
||||||
|
## Implementation Slices
|
||||||
|
|
||||||
|
1. Define package manifest and diagnostic schemas.
|
||||||
|
2. Add core configuration-package provider capability contracts.
|
||||||
|
3. Implement catalog validation and package signature verification.
|
||||||
|
4. Add dry-run orchestration against mock providers.
|
||||||
|
5. Add admin catalog/import wizard screens using provider data requirements.
|
||||||
|
6. Implement export/import providers for access-owned roles/groups first.
|
||||||
|
7. Add providers in workflow/forms/templates/mail/payments/portal/cases/tasks as
|
||||||
|
those modules mature.
|
||||||
|
8. Add round-trip tests: export a known setup, import into a clean tenant,
|
||||||
|
verify health checks.
|
||||||
|
9. Add documentation and field-level help for package authors and operators.
|
||||||
|
|
||||||
|
## Acceptance Criteria For Tracking Issue
|
||||||
|
|
||||||
|
- A Gitea tracking issue exists in `govoplan-core` for the cross-cutting
|
||||||
|
feature.
|
||||||
|
- The issue links module-specific follow-ups when implementation begins.
|
||||||
|
- The first implementation exposes typed contracts rather than direct
|
||||||
|
cross-module imports.
|
||||||
|
- A signed example catalog and unsigned development fixture exist.
|
||||||
|
- A dry-run import can identify required modules, missing data, and conflicts
|
||||||
|
before applying changes.
|
||||||
|
- An export can produce a package with secrets redacted into data requirements.
|
||||||
|
- Admin UI shows a focused wizard and actionable diagnostic list.
|
||||||
|
- Tests cover package validation, signature failure, dependency resolution,
|
||||||
|
provider preflight, export redaction, and import idempotency.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# 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.
|
||||||
|
- `PasswordField` passes its owner context and module through reveal/generate
|
||||||
|
actions and the shared generator dialog. Credential consumers must supply an
|
||||||
|
exact owner context; the generic component does not own credential policy.
|
||||||
|
|
||||||
|
High-risk controls use one of the source-inventory risk classes (`authority`,
|
||||||
|
`credential`, `disclosure`, `encryption`, `external-effect`, `irreversible`,
|
||||||
|
`policy`, or `retention`) and require exact F1 help. The extractor infers
|
||||||
|
obvious cases conservatively; components may declare `data-help-risk`
|
||||||
|
explicitly or mark a reviewed ordinary control with
|
||||||
|
`data-help-risk-reviewed="standard"`. The strict workspace gate rejects new
|
||||||
|
unresolved high-risk debt.
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Dependency Audits
|
||||||
|
|
||||||
|
GovOPlaN keeps dependency vulnerability checks reproducible but separate from
|
||||||
|
the fast local smoke suite, because both Python and npm audits need network
|
||||||
|
metadata and can fail for newly disclosed advisories without a source change.
|
||||||
|
|
||||||
|
## Local Workflow
|
||||||
|
|
||||||
|
Install the whole-product development dependencies once from the meta
|
||||||
|
repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
./.venv/bin/python -m pip install -r requirements-dev.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Run both backend and WebUI production audits:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
bash tools/checks/check-dependency-audits.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The script runs:
|
||||||
|
|
||||||
|
- `tools/checks/check-dependency-hygiene.sh` for pip resolver consistency, stale
|
||||||
|
legacy editable package metadata, deprecated framework constants, and the
|
||||||
|
Starlette `TestClient` deprecation smoke when test dependencies are present
|
||||||
|
- `python -m pip_audit --progress-spinner off`
|
||||||
|
- `npm audit --omit=dev` in `webui`
|
||||||
|
|
||||||
|
For fast local checks without vulnerability metadata lookups, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
CHECK_TESTCLIENT_DEPRECATIONS=1 \
|
||||||
|
bash tools/checks/check-dependency-hygiene.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This is also part of `govoplan/tools/checks/check-focused.sh`, so resolver drift and
|
||||||
|
deprecation regressions fail close to the code change that introduced them.
|
||||||
|
|
||||||
|
Override tool paths when testing from a disposable environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHON=/tmp/govoplan-audit/bin/python \
|
||||||
|
NPM=/home/zemion/.nvm/versions/node/v22.22.3/bin/npm \
|
||||||
|
GOVOPLAN_CORE_ROOT=/mnt/DATA/git/govoplan-core \
|
||||||
|
bash /mnt/DATA/git/govoplan/tools/checks/check-dependency-audits.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI Workflow
|
||||||
|
|
||||||
|
`govoplan/.gitea/workflows/dependency-audit.yml` installs release dependencies from
|
||||||
|
tagged package refs, installs `pip-audit`, and runs the same script on pushes,
|
||||||
|
pull requests, and a weekly schedule.
|
||||||
|
|
||||||
|
The workflow intentionally uses release dependency refs instead of local
|
||||||
|
`file:` or editable sibling paths. Development lockfiles may keep local module
|
||||||
|
links, but release audit results should represent the installable product.
|
||||||
|
|
||||||
|
## Recording Results
|
||||||
|
|
||||||
|
When closing or triaging dependency-audit issues, add a short dated note under
|
||||||
|
`docs/audits/`. Record:
|
||||||
|
|
||||||
|
- the commands that were run
|
||||||
|
- whether Python and npm passed
|
||||||
|
- any advisories accepted as temporary risk
|
||||||
|
- follow-up issue links for required upgrades
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
# GovOPlaN Deployment Operator Guide
|
||||||
|
|
||||||
|
This guide defines the current install/runtime configuration contract and the
|
||||||
|
operator flow for a production-realistic self-hosted deployment. Keep secrets in
|
||||||
|
the deployment environment or a secret manager; do not commit populated `.env`
|
||||||
|
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
|
||||||
|
installer CLI/daemon for package mutation under maintenance mode.
|
||||||
|
|
||||||
|
Generate a deployment-local template:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
./.venv/bin/python -m govoplan_core.commands.config env-template \
|
||||||
|
--profile self-hosted \
|
||||||
|
--generate-secrets \
|
||||||
|
--output .env.self-hosted
|
||||||
|
```
|
||||||
|
|
||||||
|
Validate the active shell environment before migration or startup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -a
|
||||||
|
. .env.self-hosted
|
||||||
|
set +a
|
||||||
|
./.venv/bin/python -m govoplan_core.commands.config validate --profile self-hosted
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required Runtime Identity
|
||||||
|
|
||||||
|
| 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 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. |
|
||||||
|
|
||||||
|
Generate a local key for a new non-production environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python - <<'PY'
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
print(Fernet.generate_key().decode())
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database And Migrations
|
||||||
|
|
||||||
|
| Setting | Default | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `DATABASE_URL` | `postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev` | Local development and production-like profiles use PostgreSQL. Use `GOVOPLAN_DEV_DATABASE_BACKEND=sqlite` only for disposable SQLite runs. |
|
||||||
|
| `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
|
||||||
|
backup/restore hooks for the module installer.
|
||||||
|
|
||||||
|
### PostgreSQL Production Target
|
||||||
|
|
||||||
|
PostgreSQL is the primary development and production target. SQLite remains
|
||||||
|
supported only for tiny disposable profiles and unit-test style smoke runs.
|
||||||
|
Production/staging deployments should use a managed PostgreSQL database and
|
||||||
|
explicit migration commands.
|
||||||
|
|
||||||
|
Install the full release profile from the meta repository so core, modules, and
|
||||||
|
the `psycopg` driver are available:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
./.venv/bin/python -m pip install -r requirements-release.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Example runtime database URLs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DATABASE_URL='postgresql+psycopg://govoplan:change-me@db.example.internal:5432/govoplan'
|
||||||
|
export GOVOPLAN_DATABASE_URL_PGTOOLS='postgresql://govoplan:change-me@db.example.internal:5432/govoplan'
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the SQLAlchemy URL for GovOPlaN. Use the pg-tools URL for `pg_dump`,
|
||||||
|
`pg_restore`, and `psql`; these tools do not understand the
|
||||||
|
`postgresql+psycopg://` driver marker.
|
||||||
|
|
||||||
|
Bootstrap or upgrade the schema explicitly during deployment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export APP_ENV=prod
|
||||||
|
export ENABLED_MODULES=tenancy,access,admin,policy,audit,campaigns,files,mail,calendar,docs,ops
|
||||||
|
./.venv/bin/python -m govoplan_core.commands.init_db \
|
||||||
|
--database-url "$DATABASE_URL"
|
||||||
|
```
|
||||||
|
|
||||||
|
Backup and restore-check before migration-bearing package changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pg_dump --format=custom \
|
||||||
|
--file "$PWD/runtime/govoplan-$(date +%Y%m%d%H%M%S).dump" \
|
||||||
|
"$GOVOPLAN_DATABASE_URL_PGTOOLS"
|
||||||
|
pg_restore --list "$PWD/runtime/govoplan-YYYYMMDDHHMMSS.dump" >/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
Restore a checked backup to the target database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pg_restore --clean --if-exists \
|
||||||
|
--dbname "$GOVOPLAN_DATABASE_URL_PGTOOLS" \
|
||||||
|
"$PWD/runtime/govoplan-YYYYMMDDHHMMSS.dump"
|
||||||
|
```
|
||||||
|
|
||||||
|
For local development, create the host database described in
|
||||||
|
`/mnt/DATA/git/govoplan/dev/postgres/README.md`, then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
./.venv/bin/python -m govoplan_core.commands.init_db \
|
||||||
|
--database-url postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev \
|
||||||
|
--with-dev-data
|
||||||
|
./.venv/bin/python -m govoplan_core.devserver --smoke --no-reload
|
||||||
|
tools/launch/launch-dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
For disposable local validation against a throwaway PostgreSQL instance, use
|
||||||
|
the bundled PostgreSQL testbed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan/dev/postgres
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose --env-file .env up -d
|
||||||
|
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
set -a
|
||||||
|
. /mnt/DATA/git/govoplan/dev/postgres/.env
|
||||||
|
set +a
|
||||||
|
tools/checks/postgres-integration-check.py \
|
||||||
|
--database-url "$GOVOPLAN_POSTGRES_DATABASE_URL" \
|
||||||
|
--reset-schema
|
||||||
|
```
|
||||||
|
|
||||||
|
The integration check runs migrations and startup smoke checks across the
|
||||||
|
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
|
||||||
|
|
||||||
|
| Setting | Default | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `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,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,mail,calendar,dataflow,workflow,postbox,events,idm,default \
|
||||||
|
--loglevel INFO
|
||||||
|
```
|
||||||
|
|
||||||
|
Run Celery beat as a separately supervised process. Its built-in one-minute
|
||||||
|
schedule recovers Calendar outbox rows left behind by broker failures, process
|
||||||
|
crashes, and expired worker leases:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `FILE_STORAGE_BACKEND` | `local` | Use `local` for dev/small deployments; use object storage when files must scale independently. |
|
||||||
|
| `FILE_STORAGE_LOCAL_ROOT` | `runtime/files` | Must live on durable storage and be backed up when `FILE_STORAGE_BACKEND=local`. |
|
||||||
|
| `FILE_STORAGE_LOCAL_FALLBACK_ROOTS` | empty | Read-only fallback roots for migrated local files. |
|
||||||
|
| `FILE_STORAGE_S3_ENDPOINT_URL` | empty | Object-store endpoint for the files module. |
|
||||||
|
| `FILE_STORAGE_S3_REGION` | empty | Object-store region. |
|
||||||
|
| `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_*`.
|
||||||
|
|
||||||
|
### HTTP, Cookies, And Base URLs
|
||||||
|
|
||||||
|
| Setting | Default | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `CORS_ORIGINS` | local dev origins | Set to the exact WebUI origins in staging/production. |
|
||||||
|
| `GOVOPLAN_TRUSTED_HOSTS` | empty | Exact API host names accepted by the application. Production-like validation requires an explicit list; narrowly scoped `*.example.org` entries are supported. |
|
||||||
|
| `FORWARDED_ALLOW_IPS` | Uvicorn default | Address or network of the trusted reverse proxy. Never use `*` in production-like deployments. |
|
||||||
|
| `AUTH_SESSION_COOKIE_NAME` | configured default | Change only through a controlled rollout because it logs users out. |
|
||||||
|
| `AUTH_CSRF_COOKIE_NAME` | configured default | Must match WebUI/API deployment. |
|
||||||
|
| `AUTH_COOKIE_SECURE` | `false` | Set `true` behind HTTPS. |
|
||||||
|
| `AUTH_COOKIE_SAMESITE` | `lax` | Use a stricter value only after testing login and CSRF flows. |
|
||||||
|
| `AUTH_COOKIE_DOMAIN` | empty | Set only when the API and WebUI intentionally share a parent domain. |
|
||||||
|
| `GOVOPLAN_HTTP_HSTS_SECONDS` | `31536000` in production, otherwise `0` | Emitted only for HTTPS requests. Set `0` while rehearsing a deployment that is not yet HTTPS-only. |
|
||||||
|
| `GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES` | `536870912` (512 MiB) | Deployment hard ceiling; file and module APIs apply their own lower limits where appropriate. |
|
||||||
|
|
||||||
|
Interactive password login is enabled with fixed-window limits of 10 failures
|
||||||
|
per normalized identity and 100 failures per direct client over 900 seconds.
|
||||||
|
`AUTH_LOGIN_THROTTLE_*` settings change those limits. Counters use `REDIS_URL`
|
||||||
|
when Redis is reachable so replicas share state. Production-like startup fails
|
||||||
|
when throttling is enabled without `REDIS_URL`. Set
|
||||||
|
`GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE=true` only as an explicit
|
||||||
|
single-process risk acceptance. A bounded process-local fallback keeps
|
||||||
|
development and temporary Redis outages functional, with per-process
|
||||||
|
enforcement until Redis recovers; monitor Redis because protection is weaker
|
||||||
|
during that fallback.
|
||||||
|
|
||||||
|
### Outbound Connector Egress
|
||||||
|
|
||||||
|
| Setting | Default | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS` | `true` in dev/test, otherwise `false` | Deployment-wide decision. Set `true` only when pinned HTTP(S), DAV, SMTP, or IMAP transports must reach internal addresses. It does not enable an SDK transport that cannot pin every peer. |
|
||||||
|
| `GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES` | `16777216` (16 MiB) | Maximum buffered JSON, XML, iCalendar, vCard, catalog, and connector error response. |
|
||||||
|
| `GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES` | `536870912` (512 MiB) | Hard upper bound for a single remote file; module upload limits may be lower. |
|
||||||
|
| `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST` | empty | Comma-separated exact environment names usable by deployment-owned connector profiles. Tenant/API-managed profiles cannot select process variables, even when a name is listed. |
|
||||||
|
| `GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST` | empty | Comma-separated exact absolute CA bundle paths. Mount the same files at the same paths on every API and connector worker. |
|
||||||
|
|
||||||
|
Production-like configuration validation requires the private-network choice to
|
||||||
|
be explicit. HTTP connector downloads are streamed up to the configured bound,
|
||||||
|
and credential-bearing DAV redirects remain confined to their configured
|
||||||
|
origin.
|
||||||
|
|
||||||
|
The urllib, HTTPX/httpcore, SMTP, and IMAP transports resolve, validate, and
|
||||||
|
connect to the same approved address record while retaining the original host
|
||||||
|
for HTTP Host, TLS SNI, and certificate verification. Live SMB and S3 access
|
||||||
|
fails closed in both public-only and private-network deployments: the current
|
||||||
|
SDK transports cannot pin every initial and secondary peer or revalidate every
|
||||||
|
SDK-managed redirect/referral. An explicit IP endpoint does not bypass this
|
||||||
|
rule. Production deployments should still enforce the same decision at their
|
||||||
|
worker/container egress firewall or outbound proxy as a second boundary.
|
||||||
|
|
||||||
|
File connector TLS verification may be disabled only in dev/test. A custom CA
|
||||||
|
bundle must be an existing regular file whose resolved absolute path is listed
|
||||||
|
in `GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST`. Environment-backed file connector
|
||||||
|
credentials are supported only in deployment-owned connector JSON and require
|
||||||
|
their exact names in `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST`; UI/API profiles
|
||||||
|
must use encrypted stored credentials or a scoped secret-provider reference.
|
||||||
|
|
||||||
|
Public URLs are currently supplied by deployment/reverse-proxy configuration and
|
||||||
|
module settings. Do not hardcode them in core; configuration packages should ask
|
||||||
|
for portal, WebUI, postbox, and notification URLs when they become relevant.
|
||||||
|
Uvicorn applies `X-Forwarded-*` only from `FORWARDED_ALLOW_IPS`; keep that value
|
||||||
|
aligned with the reverse proxy and do not expose the application server directly
|
||||||
|
through the same trusted address range.
|
||||||
|
|
||||||
|
### Module Catalogs, Licenses, And Trust Roots
|
||||||
|
|
||||||
|
| Setting | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `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_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. 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
|
||||||
|
|
||||||
|
Dedicated SMTP/IMAP test credentials belong to the mail/campaign test-bed
|
||||||
|
configuration, not the core runtime contract. Store them in a local ignored
|
||||||
|
`.env` file for the test bed or in CI secrets. Required values are:
|
||||||
|
|
||||||
|
- SMTP host, port, TLS mode, username, password, and envelope/from address.
|
||||||
|
- IMAP host, port, TLS mode, username, password, and append folder.
|
||||||
|
- At least one recipient mailbox that is safe for automated send tests.
|
||||||
|
|
||||||
|
## First Deployment Flow
|
||||||
|
|
||||||
|
1. Create an environment file or secret set with the runtime contract above.
|
||||||
|
2. Install the tagged core and module packages from meta `requirements-release.txt`.
|
||||||
|
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:
|
||||||
|
|
||||||
|
```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.
|
||||||
|
9. Open Admin > System > Modules, verify enabled modules, and save desired
|
||||||
|
module state if it differs from `ENABLED_MODULES`.
|
||||||
|
10. Run health checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS http://127.0.0.1:8000/health
|
||||||
|
curl -fsS http://127.0.0.1:8000/api/v1/platform/modules
|
||||||
|
```
|
||||||
|
|
||||||
|
Authenticated health details require `system:settings:read`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS -H "X-API-Key: $GOVOPLAN_HEALTH_API_KEY" \
|
||||||
|
http://127.0.0.1:8000/health/details
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production-Like Dev Profile
|
||||||
|
|
||||||
|
Use this profile to verify deployment behavior without publishing packages or
|
||||||
|
using real production credentials. The canonical launcher keeps API, worker, and
|
||||||
|
WebUI code in the editable repositories while Docker provides PostgreSQL and
|
||||||
|
Redis:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
tools/launch/launch-production-like-dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The helper wrapper provides explicit lifecycle commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/launch/production-like-dev.sh validate-config
|
||||||
|
tools/launch/production-like-dev.sh seed
|
||||||
|
tools/launch/production-like-dev.sh start
|
||||||
|
tools/launch/production-like-dev.sh stop
|
||||||
|
tools/launch/production-like-dev.sh reset --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
The launcher uses `govoplan/dev/production-like/.env` when present, otherwise
|
||||||
|
the checked in `.env.example`. It runs:
|
||||||
|
|
||||||
|
- PostgreSQL on `127.0.0.1:55433`
|
||||||
|
- Redis on `127.0.0.1:56379`
|
||||||
|
- 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,mail,calendar,dataflow,workflow,postbox,events,idm,default`
|
||||||
|
- WebUI through the Vite dev server
|
||||||
|
- durable local files under `runtime/production-like/files`
|
||||||
|
|
||||||
|
This profile validates explicit migration execution, config loading, module
|
||||||
|
discovery, route aggregation, local storage paths, Redis broker connectivity,
|
||||||
|
worker heartbeats, and health/readiness startup without real production
|
||||||
|
credentials. It does not replace a managed PostgreSQL/Redis/WebUI/worker
|
||||||
|
deployment test.
|
||||||
|
|
||||||
|
To stop PostgreSQL and Redis when the launcher exits:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GOVOPLAN_STOP_PROFILE_DEPENDENCIES_ON_EXIT=1 tools/launch/launch-production-like-dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Module Install/Uninstall Operations
|
||||||
|
|
||||||
|
Use Admin > System > Modules for planning. The running API server validates and
|
||||||
|
queues install plans; it does not run pip/npm or restart itself from an HTTP
|
||||||
|
request. Package mutation belongs to the trusted installer CLI/daemon in an
|
||||||
|
operator shell while maintenance mode is active.
|
||||||
|
|
||||||
|
Preflight from the server shell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
govoplan-module-installer --format shell
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply a prepared plan directly from a controlled shell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
govoplan-module-installer --apply --build-webui
|
||||||
|
```
|
||||||
|
|
||||||
|
For production-like runs, prefer supervised mode with migrations, database
|
||||||
|
backup/restore hooks, restart commands, and health checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
govoplan-module-installer \
|
||||||
|
--supervise \
|
||||||
|
--migrate \
|
||||||
|
--restart-command 'systemctl restart govoplan-api' \
|
||||||
|
--restart-command 'systemctl restart govoplan-worker' \
|
||||||
|
--health-url http://127.0.0.1:8000/health \
|
||||||
|
--database-backup-command 'pg_dump --format=custom "$GOVOPLAN_DATABASE_URL_PGTOOLS" > "$GOVOPLAN_DATABASE_BACKUP_PATH"' \
|
||||||
|
--database-restore-check-command 'pg_restore --list "$GOVOPLAN_DATABASE_BACKUP_PATH" >/dev/null' \
|
||||||
|
--database-restore-command 'pg_restore --clean --if-exists --dbname "$GOVOPLAN_DATABASE_URL_PGTOOLS" "$GOVOPLAN_DATABASE_BACKUP_PATH"'
|
||||||
|
```
|
||||||
|
|
||||||
|
To let the admin UI submit work without executing package managers inside the
|
||||||
|
API process, run the daemon in a separate operator shell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
govoplan-module-installer \
|
||||||
|
--daemon \
|
||||||
|
--migrate \
|
||||||
|
--build-webui \
|
||||||
|
--database-backup-command 'pg_dump --format=custom "$GOVOPLAN_DATABASE_URL_PGTOOLS" > "$GOVOPLAN_DATABASE_BACKUP_PATH"' \
|
||||||
|
--database-restore-check-command 'pg_restore --list "$GOVOPLAN_DATABASE_BACKUP_PATH" >/dev/null' \
|
||||||
|
--database-restore-command 'pg_restore --clean --if-exists --dbname "$GOVOPLAN_DATABASE_URL_PGTOOLS" "$GOVOPLAN_DATABASE_BACKUP_PATH"' \
|
||||||
|
--health-url http://127.0.0.1:8000/health \
|
||||||
|
--restart-command '<restart govoplan server>'
|
||||||
|
```
|
||||||
|
|
||||||
|
The daemon claims one queued request at a time and writes request/run records
|
||||||
|
below `runtime/module-installer`. For process-manager one-shot usage or tests,
|
||||||
|
use `--daemon-once`. Check daemon status with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
govoplan-module-installer --daemon-status --format json
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer uses a runtime lock, snapshots `pip freeze` plus WebUI package
|
||||||
|
files, writes a run record, and marks planned rows as applied only after all
|
||||||
|
commands succeed. With `--migrate`, SQLite databases are backed up through
|
||||||
|
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`
|
||||||
|
- `GOVOPLAN_DATABASE_URL`
|
||||||
|
- `GOVOPLAN_DATABASE_URL_PGTOOLS` for PostgreSQL tools
|
||||||
|
- `GOVOPLAN_DATABASE_BACKUP_PATH`
|
||||||
|
- `GOVOPLAN_DATABASE_BACKUP_METADATA`
|
||||||
|
|
||||||
|
Avoid embedding secrets directly in commands; prefer environment variables,
|
||||||
|
service credentials, or deployment-local secret injection.
|
||||||
|
|
||||||
|
Inspect installer history and lock state from the operator shell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
govoplan-module-installer --list-runs --format json
|
||||||
|
govoplan-module-installer --show-run <run-id> --format json
|
||||||
|
govoplan-module-installer --lock-status --format json
|
||||||
|
govoplan-module-installer --list-requests --format json
|
||||||
|
govoplan-module-installer --show-request <request-id> --format json
|
||||||
|
govoplan-module-installer --cancel-request <request-id> --format json
|
||||||
|
govoplan-module-installer --retry-request <request-id> --format json
|
||||||
|
```
|
||||||
|
|
||||||
|
Rollback uses the saved run snapshot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
govoplan-module-installer --rollback <run-id>
|
||||||
|
govoplan-module-installer --rollback <run-id> --database-restore-command '<override restore command>'
|
||||||
|
```
|
||||||
|
|
||||||
|
Uninstall is non-destructive by default. A planned uninstall row can set
|
||||||
|
`destroy_data: true` to request destructive module retirement. The module must
|
||||||
|
provide an automated retirement provider, and the installer snapshots the
|
||||||
|
database before dropping module-owned tables.
|
||||||
|
|
||||||
|
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 \
|
||||||
|
--evidence-path runtime/module-installer/restore-drill-evidence.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The drill uses temporary SQLite databases and simulated package commands. It
|
||||||
|
does not install or uninstall real packages. It exercises:
|
||||||
|
|
||||||
|
- package command failure followed by supervised rollback;
|
||||||
|
- migration failure with a SQLite database snapshot;
|
||||||
|
- restart-command failure;
|
||||||
|
- health timeout after restart;
|
||||||
|
- destructive retirement executor failure with database rollback;
|
||||||
|
- PostgreSQL-style backup, restore-check, and restore hooks;
|
||||||
|
- daemon heartbeat, request queue claim/update, retry/cancel, and stale lock
|
||||||
|
detection/removal.
|
||||||
|
|
||||||
|
See `RELEASE_DEPENDENCIES.md` for release package refs, migration baseline
|
||||||
|
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; 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.
|
||||||
|
- Redis and workers are running before `CELERY_ENABLED=true`.
|
||||||
|
- Module catalog and license keyrings are pinned locally.
|
||||||
|
- Health endpoints are monitored.
|
||||||
|
- Test SMTP/IMAP credentials are non-production and isolated.
|
||||||
|
- Module installer rollback drill has passed in the deployment environment.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# GovOPlaN Documentation Map
|
||||||
|
|
||||||
|
This map defines the source-of-truth documents for the current repository docs.
|
||||||
|
Use it to avoid duplicating long procedures across architecture, release,
|
||||||
|
operator, and roadmap pages.
|
||||||
|
|
||||||
|
## Core Platform
|
||||||
|
|
||||||
|
| 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
|
||||||
|
|
||||||
|
| Topic | Canonical document | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Runtime configuration and operator flow | `DEPLOYMENT_OPERATOR_GUIDE.md` | Production/staging configuration, migrations, backups, installer operation, and rollback drill. |
|
||||||
|
| Self-hosted installability | `SELF_HOSTED_INSTALLABILITY.md` | Packaging decision, generated env templates, config validation, production-like dev stack commands, and boundary gate. |
|
||||||
|
| 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. |
|
||||||
|
|
||||||
|
## Workflow Docs
|
||||||
|
|
||||||
|
| Topic | Canonical document | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Gitea issues and wiki sync | `GITEA_ISSUES.md` | Issue labels, imports, wiki mirroring, and Codex state updates. |
|
||||||
|
| Codex local workflow | `CODEX_WORKFLOW.md` | Local agent setup and focused verification commands. |
|
||||||
|
|
||||||
|
## Cross-Repo Rule
|
||||||
|
|
||||||
|
Core docs may keep strategy, kernel contracts, and routing decisions. Module
|
||||||
|
repositories should own executable module behavior, concrete API/UI contracts,
|
||||||
|
and operator notes for their own module. When content spans both, keep the
|
||||||
|
durable decision in core and link to the module document for implementation
|
||||||
|
details.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
# Events And Audit
|
||||||
|
|
||||||
|
GovOPlaN uses a small kernel event contract first, not a broad command bus.
|
||||||
|
Commands remain module-owned application-service methods or API endpoints until
|
||||||
|
there is a concrete need for durable asynchronous command orchestration. Events
|
||||||
|
are facts about completed work and are safe for audit, projections, optional
|
||||||
|
module reactions, and operator diagnostics.
|
||||||
|
|
||||||
|
## Production Transport Decision
|
||||||
|
|
||||||
|
The production transport is a **transactional database outbox plus retrying
|
||||||
|
dispatcher**:
|
||||||
|
|
||||||
|
- Use `govoplan_core.core.events.PlatformEvent` for domain and platform events.
|
||||||
|
- 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 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 transactionally emits a generic module change event such as
|
||||||
|
`mail.profile.updated`.
|
||||||
|
- 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. 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.
|
||||||
|
- Keep commands out of the kernel until workflows need retryable, durable,
|
||||||
|
operator-visible command records.
|
||||||
|
|
||||||
|
This keeps the first contract small, PostgreSQL-friendly, auditable, and
|
||||||
|
recoverable after process crashes. It also avoids making Redis a correctness
|
||||||
|
dependency for deployments that only need synchronous mail/tests or light
|
||||||
|
background work.
|
||||||
|
|
||||||
|
## Dispatch Semantics
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
The current outbox stores:
|
||||||
|
|
||||||
|
- `event_id`, `event_type`, `module_id`
|
||||||
|
- `correlation_id`, `causation_id`
|
||||||
|
- `classification`, serialized event `payload`
|
||||||
|
- `status`, `attempts`, `next_attempt_at`
|
||||||
|
- `dispatched_at`, `last_error`, timestamps
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Every `PlatformEvent` has:
|
||||||
|
|
||||||
|
- `event_id`: unique ID for that event.
|
||||||
|
- `correlation_id`: stable ID for the whole request, workflow, or job.
|
||||||
|
- `causation_id`: the event ID or external operation ID that caused this
|
||||||
|
event.
|
||||||
|
- `actor`: optional typed actor reference, for example user, API key, system
|
||||||
|
actor, delegated actor, or installer daemon.
|
||||||
|
- `tenant`: optional tenant reference when the event is tenant-scoped.
|
||||||
|
- `subject`: optional typed subject reference for the person, organization,
|
||||||
|
account, case, campaign, or other entity the event is about.
|
||||||
|
- `resource`: optional typed resource reference for the object changed or
|
||||||
|
observed by the event.
|
||||||
|
- `classification`: payload sensitivity, currently `public`, `internal`,
|
||||||
|
`confidential`, or `restricted`.
|
||||||
|
- `payload`: JSON-serializable module-owned event details.
|
||||||
|
|
||||||
|
The FastAPI app factory creates an event context for every request. It accepts
|
||||||
|
`X-Correlation-ID` or `X-Request-ID` when the value is a compact safe trace ID,
|
||||||
|
otherwise it generates a new ID. Responses include `X-Correlation-ID`.
|
||||||
|
|
||||||
|
Audit logging reads the current event context and stores trace IDs in
|
||||||
|
`details._trace`. Callers can also pass explicit `correlation_id` and
|
||||||
|
`causation_id` to `audit_event` or `audit_from_principal`. The same trace is
|
||||||
|
copied into the emitted `PlatformEvent`, so audit rows and event subscribers can
|
||||||
|
be correlated without route-specific glue code.
|
||||||
|
|
||||||
|
Admin and lifecycle code should use the compact operational detail shape
|
||||||
|
documented in `govoplan-audit/docs/AUDIT_TRACE_CONTEXT.md`. The core
|
||||||
|
`audit_operation_context` helper preserves `module_id`, `request_id`, `run_id`,
|
||||||
|
`outcome`, and `_trace` while applying the shared audit redaction pass to
|
||||||
|
additional detail values.
|
||||||
|
|
||||||
|
## Audit MVP Boundary
|
||||||
|
|
||||||
|
`govoplan-audit` owns:
|
||||||
|
|
||||||
|
- the `audit_log` table and audit API routes
|
||||||
|
- audit route contribution through its module manifest
|
||||||
|
- audit retention behavior in cooperation with policy/retention settings
|
||||||
|
- future audit sink/export capability implementations
|
||||||
|
|
||||||
|
`govoplan-core` owns:
|
||||||
|
|
||||||
|
- `AuditEvent` and `AuditSink` protocol contracts
|
||||||
|
- request and event trace context
|
||||||
|
- the compatibility `audit_event` helper while routes are still migrating
|
||||||
|
- retention orchestration that calls module capabilities
|
||||||
|
|
||||||
|
Feature modules should record audit facts through the shared helper or a future
|
||||||
|
`audit.sink` capability. They should not import audit storage internals. Module
|
||||||
|
state changes that only need delta-feed visibility should go through
|
||||||
|
`record_change`; route-level business actions should still record a semantic
|
||||||
|
audit action.
|
||||||
|
|
||||||
|
## Initial Domain Event Inventory
|
||||||
|
|
||||||
|
Access:
|
||||||
|
|
||||||
|
- `access.account.created`
|
||||||
|
- `access.account.updated`
|
||||||
|
- `access.membership.created`
|
||||||
|
- `access.membership.updated`
|
||||||
|
- `access.group.created`
|
||||||
|
- `access.group.updated`
|
||||||
|
- `access.role.created`
|
||||||
|
- `access.role.updated`
|
||||||
|
- `access.role.deleted`
|
||||||
|
- `access.api_key.created`
|
||||||
|
- `access.api_key.revoked`
|
||||||
|
- `access.session.created`
|
||||||
|
- `access.session.revoked`
|
||||||
|
|
||||||
|
Tenancy:
|
||||||
|
|
||||||
|
- `tenant.created`
|
||||||
|
- `tenant.updated`
|
||||||
|
- `tenant.suspended`
|
||||||
|
- `tenant.resumed`
|
||||||
|
- `tenant.deletion_requested`
|
||||||
|
- `tenant.erasure_completed`
|
||||||
|
|
||||||
|
Policy:
|
||||||
|
|
||||||
|
- `policy.system.updated`
|
||||||
|
- `policy.tenant.updated`
|
||||||
|
- `policy.user.updated`
|
||||||
|
- `policy.group.updated`
|
||||||
|
- `policy.campaign.updated`
|
||||||
|
- `policy.effective_policy.changed`
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `files.file.uploaded`
|
||||||
|
- `files.file.renamed`
|
||||||
|
- `files.file.deleted`
|
||||||
|
- `files.file.frozen`
|
||||||
|
- `files.share.created`
|
||||||
|
- `files.share.revoked`
|
||||||
|
- `files.connector.imported`
|
||||||
|
- `files.connector.access_denied`
|
||||||
|
|
||||||
|
Mail:
|
||||||
|
|
||||||
|
- `mail.profile.created`
|
||||||
|
- `mail.profile.updated`
|
||||||
|
- `mail.profile.credentials_rotated`
|
||||||
|
- `mail.profile.tested`
|
||||||
|
- `mail.message.sent`
|
||||||
|
- `mail.message.send_failed`
|
||||||
|
- `mail.imap.appended`
|
||||||
|
- `mail.imap.append_failed`
|
||||||
|
- `mail.mailbox.message_seen`
|
||||||
|
|
||||||
|
Campaign:
|
||||||
|
|
||||||
|
- `campaign.created`
|
||||||
|
- `campaign.version.created`
|
||||||
|
- `campaign.validated`
|
||||||
|
- `campaign.built`
|
||||||
|
- `campaign.reviewed`
|
||||||
|
- `campaign.queued`
|
||||||
|
- `campaign.send_started`
|
||||||
|
- `campaign.recipient_attempted`
|
||||||
|
- `campaign.recipient_delivered`
|
||||||
|
- `campaign.recipient_failed`
|
||||||
|
- `campaign.paused`
|
||||||
|
- `campaign.resumed`
|
||||||
|
- `campaign.cancelled`
|
||||||
|
- `campaign.report.exported`
|
||||||
|
|
||||||
|
## Event Payload Rules
|
||||||
|
|
||||||
|
- Payloads must be JSON-serializable.
|
||||||
|
- Use stable IDs, not ORM objects.
|
||||||
|
- Include tenant ID when tenant-scoped.
|
||||||
|
- Include actor/principal, subject, and resource references only as typed DTOs
|
||||||
|
or primitive IDs.
|
||||||
|
- Set `classification` to the highest sensitivity needed by the envelope or
|
||||||
|
payload, not the lowest sensitivity of any individual field.
|
||||||
|
- Do not include secrets, raw message bodies, full recipient lists, or file
|
||||||
|
content.
|
||||||
|
- Put large evidence in owning module storage and reference it by ID.
|
||||||
@@ -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`.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Gitea Issues And Wiki Workflow
|
||||||
|
|
||||||
|
The shared GovOPlaN Gitea issue, label, and wiki workflow tooling moved to the
|
||||||
|
meta repository.
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
tools/gitea/gitea-sync-labels.py --help
|
||||||
|
tools/gitea/gitea-sync-wiki.py --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Canonical documentation:
|
||||||
|
|
||||||
|
- `/mnt/DATA/git/govoplan/docs/project/GITEA_ISSUES.md`
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
# GovOPlaN Governance Model
|
||||||
|
|
||||||
|
**Updated:** 2026-07-09
|
||||||
|
|
||||||
|
## Governance Rule
|
||||||
|
|
||||||
|
System policy is authoritative for tenants and all lower levels. Each lower
|
||||||
|
level may only narrow what it inherits:
|
||||||
|
|
||||||
|
```text
|
||||||
|
system
|
||||||
|
-> tenant
|
||||||
|
-> user or group owner
|
||||||
|
-> campaign
|
||||||
|
```
|
||||||
|
|
||||||
|
Lower levels do not widen privileges, allowed profiles, retention durations, or
|
||||||
|
credential rights granted by a higher level.
|
||||||
|
|
||||||
|
## Administration Structure
|
||||||
|
|
||||||
|
GovOPlaN separates system administration from scoped configuration:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ADMINISTRATION
|
||||||
|
- Modules
|
||||||
|
- Packages
|
||||||
|
- Maintenance
|
||||||
|
- Changes
|
||||||
|
|
||||||
|
GLOBAL
|
||||||
|
- Tenants
|
||||||
|
- Roles
|
||||||
|
- Groups and users
|
||||||
|
- File connectors
|
||||||
|
- Mail servers
|
||||||
|
- API keys
|
||||||
|
- Retention
|
||||||
|
|
||||||
|
TENANT
|
||||||
|
- Roles
|
||||||
|
- Groups and users
|
||||||
|
- File connectors
|
||||||
|
- Mail servers
|
||||||
|
- API keys
|
||||||
|
- Retention
|
||||||
|
|
||||||
|
GROUP
|
||||||
|
- File connectors
|
||||||
|
- Mail servers
|
||||||
|
- API keys
|
||||||
|
- Retention
|
||||||
|
|
||||||
|
USER
|
||||||
|
- File connectors
|
||||||
|
- Mail servers
|
||||||
|
- API keys
|
||||||
|
- Retention
|
||||||
|
```
|
||||||
|
|
||||||
|
System access scopes remain in the backend for assignment/read boundaries, but
|
||||||
|
the UI should present the configuration hierarchy rather than a separate
|
||||||
|
"system access" concept.
|
||||||
|
|
||||||
|
## Tenant Governance
|
||||||
|
|
||||||
|
System settings define tenant defaults and whether tenants may narrow selected
|
||||||
|
options. Tenant overrides can only restrict:
|
||||||
|
|
||||||
|
- custom groups;
|
||||||
|
- custom roles;
|
||||||
|
- tenant API keys.
|
||||||
|
|
||||||
|
The backend enforces that tenant governance cannot widen system-denied
|
||||||
|
privileges.
|
||||||
|
|
||||||
|
## Mail-Profile Governance
|
||||||
|
|
||||||
|
Mail server profiles may exist at these scopes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
system
|
||||||
|
tenant
|
||||||
|
user
|
||||||
|
group
|
||||||
|
campaign
|
||||||
|
```
|
||||||
|
|
||||||
|
Effective campaign profile availability follows campaign ownership. A campaign
|
||||||
|
owned by a user resolves through system, tenant, that user, and campaign
|
||||||
|
policy. A group-owned campaign resolves through system, tenant, that group, and
|
||||||
|
campaign policy.
|
||||||
|
|
||||||
|
Policy semantics:
|
||||||
|
|
||||||
|
- higher levels define the maximum available profile set;
|
||||||
|
- lower levels can further restrict the set;
|
||||||
|
- forced profiles mean the lower level must choose from the forced set;
|
||||||
|
- a forced set with one profile effectively enforces that profile;
|
||||||
|
- campaign-level profile creation is allowed only if the effective policy
|
||||||
|
permits it;
|
||||||
|
- SMTP/IMAP credentials use one inheritance decision per protocol: lower levels
|
||||||
|
must inherit profile credentials, may inherit profile credentials, or must
|
||||||
|
provide local credentials;
|
||||||
|
- the lower-level override switch for `smtp_credentials.inherit` and
|
||||||
|
`imap_credentials.inherit` controls whether descendants may change that
|
||||||
|
inheritance decision;
|
||||||
|
- deny patterns always win over allow patterns;
|
||||||
|
- empty or `*` allowlist means allow all except denied;
|
||||||
|
- non-empty allowlist means at least one allow rule must match and no deny rule
|
||||||
|
may match.
|
||||||
|
|
||||||
|
Pattern targets:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SMTP hostname
|
||||||
|
IMAP hostname
|
||||||
|
envelope sender
|
||||||
|
From header
|
||||||
|
recipient domains
|
||||||
|
```
|
||||||
|
|
||||||
|
Ownership transfer is intentionally deferred as a two-step workflow: original
|
||||||
|
owner initiates, new owner accepts and reselects/repairs the mail profile if
|
||||||
|
their effective policy requires it.
|
||||||
|
|
||||||
|
## File-Connector Governance
|
||||||
|
|
||||||
|
File connector profiles and credentials are separated. Profiles describe
|
||||||
|
external endpoints; credentials bind authentication material and policy to a
|
||||||
|
scope. Concrete linked folders appear as file spaces in the files module.
|
||||||
|
|
||||||
|
Governance follows the same inheritance shape as mail:
|
||||||
|
|
||||||
|
- system and tenant policy can permit, require, or forbid lower-level
|
||||||
|
connections/credentials;
|
||||||
|
- user or group ownership controls which spaces appear to principals;
|
||||||
|
- spaces inherit endpoint and credential policy from their connector profile;
|
||||||
|
- connector health and credential tests must not reveal plaintext secrets.
|
||||||
|
|
||||||
|
## Retention Governance
|
||||||
|
|
||||||
|
Retention policy is hierarchical:
|
||||||
|
|
||||||
|
```text
|
||||||
|
system -> tenant -> user/group -> campaign
|
||||||
|
```
|
||||||
|
|
||||||
|
Managed fields:
|
||||||
|
|
||||||
|
- raw campaign JSON retention days;
|
||||||
|
- generated EML retention days;
|
||||||
|
- stored report detail retention days;
|
||||||
|
- mock mailbox retention days;
|
||||||
|
- audit detail retention days.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- system may set concrete defaults or unlimited retention;
|
||||||
|
- system exposes allow-limiting toggles per field;
|
||||||
|
- tenants, users/groups, and campaigns may only shorten inherited retention
|
||||||
|
where the parent allows limiting;
|
||||||
|
- blank lower-level values inherit;
|
||||||
|
- mock mailbox retention is currently system-level because mock mailbox records
|
||||||
|
do not yet carry tenant/campaign ownership metadata;
|
||||||
|
- dry-run/apply retention actions report affected classes before destructive
|
||||||
|
cleanup.
|
||||||
|
|
||||||
|
## Role Definitions And Assignments
|
||||||
|
|
||||||
|
### System Roles
|
||||||
|
|
||||||
|
System roles define instance-wide permissions. `system:*` is stored as one
|
||||||
|
wildcard and displayed as granting the full system catalogue. System owner is
|
||||||
|
protected.
|
||||||
|
|
||||||
|
### Tenant Roles
|
||||||
|
|
||||||
|
Tenant roles can be system-governed templates or tenant-local definitions,
|
||||||
|
subject to system tenant-governance settings and actor delegation ceilings.
|
||||||
|
Wildcard counts are expanded against the canonical tenant catalogue.
|
||||||
|
|
||||||
|
## Audit Access
|
||||||
|
|
||||||
|
Audit access remains scope-separated:
|
||||||
|
|
||||||
|
```text
|
||||||
|
system audit -> system:audit:read
|
||||||
|
tenant audit -> active tenant + audit:read
|
||||||
|
```
|
||||||
|
|
||||||
|
Audit pages use server pagination, filtering, and bounded grids.
|
||||||
|
|
||||||
|
## Tenant Switching
|
||||||
|
|
||||||
|
Tenant switching preserves the current URL when possible and falls back when a
|
||||||
|
route/resource is not accessible in the new tenant context.
|
||||||
|
|
||||||
|
The tenant selector is hidden for ordinary single-tenant accounts and visible
|
||||||
|
for multi-tenant or system tenant-management contexts.
|
||||||
|
|
||||||
|
## Administration DataGrid Contract
|
||||||
|
|
||||||
|
Admin lists use bounded container grids:
|
||||||
|
|
||||||
|
- one flexible fill column;
|
||||||
|
- fixed total table width;
|
||||||
|
- compact action/status/count columns;
|
||||||
|
- resizable text/date columns;
|
||||||
|
- no intrinsic content growth;
|
||||||
|
- sticky headers where needed;
|
||||||
|
- server pagination for audit.
|
||||||
|
|
||||||
|
## Deferred Work
|
||||||
|
|
||||||
|
- DataGrid sizing and resize behavior remains explicitly deferred. The current
|
||||||
|
bounded-grid contract above is binding, but further layout changes should be
|
||||||
|
handled as a dedicated, isolated UI debt item because the component is shared
|
||||||
|
and brittle.
|
||||||
|
- real SMTP/IMAP test-bed verification and operator runbook;
|
||||||
|
- recipient import with column mapping;
|
||||||
|
- session/device revocation UI;
|
||||||
|
- backup/restore, monitoring, and update procedures;
|
||||||
|
- 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;
|
||||||
|
- destructive tenant erasure orchestration.
|
||||||
@@ -0,0 +1,742 @@
|
|||||||
|
# GovOPlaN Master Roadmap
|
||||||
|
|
||||||
|
This roadmap is the technical and module-sequencing companion for GovOPlaN as
|
||||||
|
a modular platform for administrative operations. It translates the
|
||||||
|
cross-product outcome horizons into dependency waves without turning every
|
||||||
|
possible public-sector need into an immediate implementation track.
|
||||||
|
|
||||||
|
Use this document for technical sequencing, module routing, and implementation
|
||||||
|
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
|
||||||
|
[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/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.
|
||||||
|
|
||||||
|
## Product Thesis
|
||||||
|
|
||||||
|
GovOPlaN should become a configurable operations platform for public
|
||||||
|
institutions. It should help an institution model real administrative
|
||||||
|
procedures, connect existing systems, keep durable evidence, and explain the
|
||||||
|
configured system to users.
|
||||||
|
|
||||||
|
The goal is not to replace every existing system. GovOPlaN should connect
|
||||||
|
existing systems, provide better workflows where the current landscape is weak,
|
||||||
|
and make administrative processes configurable, auditable, and reusable.
|
||||||
|
|
||||||
|
The product should first provide a reliable administrative spine:
|
||||||
|
|
||||||
|
- identities, roles, tenants, policy, audit, and governance
|
||||||
|
- forms, files, cases, workflow, tasks, templates, and records
|
||||||
|
- postboxes, notifications, mail, portal, appointments, and booking
|
||||||
|
- identity trust, role-bound postboxes, and eventually encrypted administrative
|
||||||
|
communication
|
||||||
|
- configuration packages that assemble modules into repeatable procedures
|
||||||
|
- docs that explain the configured system, not the full theoretical product
|
||||||
|
|
||||||
|
Domain modules should come after the spine can run a reference procedure end to
|
||||||
|
end.
|
||||||
|
|
||||||
|
The platform should be a governance-capable runtime for modules, connectors,
|
||||||
|
configuration, and administrative decisions. The kernel must stay free of domain
|
||||||
|
semantics while still providing the contracts needed for modules to explain what
|
||||||
|
they do, what they require, which effects they create, and how operators can
|
||||||
|
verify or reverse those effects.
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
- Modules must stay independently installable, enableable, and disableable.
|
||||||
|
- Cross-module behavior should use core-mediated capabilities, commands,
|
||||||
|
events, DTOs, and UI contribution points rather than direct imports.
|
||||||
|
- Configuration packages should turn installed modules into concrete, reusable
|
||||||
|
administrative processes.
|
||||||
|
- Operators should be able to configure the platform through the UI.
|
||||||
|
- Every powerful configuration path needs preflight, preview, audit, rollback,
|
||||||
|
RBAC, and policy checks.
|
||||||
|
- Context, decision, consequence, and traceability must be visible together for
|
||||||
|
consequential actions. The full doctrine lives in
|
||||||
|
`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`; 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`.
|
||||||
|
- Integration should be a first-class product path: connect to existing
|
||||||
|
systems, consume their data, and publish governed outputs back to them.
|
||||||
|
- GovOPlaN should scale from a small local installation to a larger deployment
|
||||||
|
with separately scalable web, API, worker, storage, and database components.
|
||||||
|
|
||||||
|
## User Experience Direction
|
||||||
|
|
||||||
|
GovOPlaN should expose the full power of the platform without forcing
|
||||||
|
non-technical users to face every field, flag, and internal representation at
|
||||||
|
once. The default experience should feel guided, explainable, and calm. Expert
|
||||||
|
depth should remain available, but it should be layered behind deliberate
|
||||||
|
interaction patterns.
|
||||||
|
|
||||||
|
Core UX rules:
|
||||||
|
|
||||||
|
- Use progressive disclosure. Common decisions stay visible; advanced,
|
||||||
|
hazardous, or rarely used options live in collapsed panels, secondary steps,
|
||||||
|
or explicit advanced areas.
|
||||||
|
- Do not use raw JSON as the primary configuration UI. Every configurable value
|
||||||
|
should have an appropriate control, validation, and plain-language help.
|
||||||
|
Import/export and diagnostics may show JSON as a secondary artifact.
|
||||||
|
- Prefer guided flows over option dumps. Connector setup, package import,
|
||||||
|
module installation, policy changes, and destructive operations should use
|
||||||
|
wizards that explain what is happening, why it matters, and what will happen
|
||||||
|
next.
|
||||||
|
- Discover values when the system can infer them. For example, a Nextcloud file
|
||||||
|
connection should start with the base URL, discover the WebDAV endpoint, and
|
||||||
|
fill technical fields for review instead of asking the user to know them
|
||||||
|
upfront.
|
||||||
|
- Make explanations always available without making every screen verbose.
|
||||||
|
Inline helper text should be short; richer explanations should be reachable
|
||||||
|
through expandable help, tooltips, side panels, or review steps.
|
||||||
|
- Explain blocked actions in actionable language. A disabled control or failed
|
||||||
|
step should say what is missing, who can fix it, and where to go, for example
|
||||||
|
"A system administrator must allow this provider" or "Configure the provider
|
||||||
|
in Settings > File Providers before linking a folder here."
|
||||||
|
- Reuse visual language and placements consistently. Similar configuration,
|
||||||
|
policy, connection, credential, review, and confirmation flows should share
|
||||||
|
components, button placement, modal behavior, problem lists, and empty/error
|
||||||
|
states.
|
||||||
|
- Use modals and step flows for focused creation/editing where they reduce page
|
||||||
|
clutter. Reserve large always-open pages for overview, comparison, and
|
||||||
|
repeated administration work.
|
||||||
|
- Treat diagnostics as product UX. Validation results, preflight blockers,
|
||||||
|
policy explanations, permission denials, and missing capabilities should be
|
||||||
|
understandable to a non-technical operator before exposing internal details.
|
||||||
|
|
||||||
|
This is a product quality gate. New admin/configuration surfaces should not be
|
||||||
|
considered complete if they expose all options at once, require JSON editing,
|
||||||
|
hide why an action is unavailable, or use a one-off layout where a shared
|
||||||
|
pattern exists.
|
||||||
|
|
||||||
|
## Focus Rules
|
||||||
|
|
||||||
|
1. Build one selected reference journey stage at a time; a later capability
|
||||||
|
cluster is not an active program merely because it appears below.
|
||||||
|
2. Do not implement a module because the repository exists.
|
||||||
|
3. Do not add module-to-module imports for optional behavior.
|
||||||
|
4. Every new domain module must justify its own semantics beyond `cases`,
|
||||||
|
`workflow`, `tasks`, `forms`, and `files`.
|
||||||
|
5. Prefer connector first when an external specialist system is likely to remain
|
||||||
|
the system of record.
|
||||||
|
6. Keep active work in Gitea issues; keep durable context in docs and synced
|
||||||
|
wiki pages.
|
||||||
|
7. A module moves from scaffold to implementation only when it has an owner,
|
||||||
|
reference journey, boundary notes, capability contracts, and testable MVP.
|
||||||
|
|
||||||
|
## Capability Map
|
||||||
|
|
||||||
|
| Capability | Likely owner |
|
||||||
|
| --- | --- |
|
||||||
|
| Public application entry point | `govoplan-portal` |
|
||||||
|
| Structured forms and validation | `govoplan-forms` |
|
||||||
|
| Uploaded files and managed storage | `govoplan-files` |
|
||||||
|
| Case record and lifecycle | `govoplan-cases` |
|
||||||
|
| Workflow runtime, transitions, and automation | `govoplan-workflow-engine` |
|
||||||
|
| Workflow definition editing | optional `govoplan-workflow` |
|
||||||
|
| Action/effect catalogue and automation runner | first `govoplan-workflow-engine`; possible future `govoplan-automation` only if it outgrows workflow |
|
||||||
|
| Internal work queues and tasks | `govoplan-tasks` |
|
||||||
|
| Appointment proposals and booking | `govoplan-appointments`, `govoplan-calendar` |
|
||||||
|
| Postbox, email, and notifications | `govoplan-postbox`, `govoplan-mail`, `govoplan-notifications` |
|
||||||
|
| Canonical subjects and account links | `govoplan-identity` |
|
||||||
|
| Organizational structures, units, and functions | `govoplan-organizations` |
|
||||||
|
| Identity-to-function assignments and directory synchronization | `govoplan-idm` |
|
||||||
|
| Identity trust, device keys, and encrypted postbox key contracts | `govoplan-identity-trust`, `govoplan-access`, `govoplan-postbox` |
|
||||||
|
| Service directory 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` |
|
||||||
|
| 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
|
||||||
|
|
||||||
|
The long-term target is that operators configure the platform through the UI
|
||||||
|
instead of editing files for normal operation.
|
||||||
|
|
||||||
|
UI-managed configuration should include:
|
||||||
|
|
||||||
|
- module installation, enablement, lifecycle state, and health
|
||||||
|
- tenants, users, groups, roles, policies, and permissions
|
||||||
|
- connectors, credentials, secret references, and external service tests
|
||||||
|
- workflows, forms, templates, task queues, schedules, and notifications
|
||||||
|
- configuration package import/export and environment-specific data collection
|
||||||
|
- retention, audit, privacy, maintenance mode, and safety controls
|
||||||
|
- deployment-visible settings such as public URLs, mail senders, storage
|
||||||
|
profiles, queues, and worker capabilities
|
||||||
|
|
||||||
|
Safety controls should include dry-run plans, field-level validation, policy
|
||||||
|
explanations, two-person approval for destructive changes, versioned
|
||||||
|
configuration history, rollback paths, audit events, and maintenance-mode
|
||||||
|
guards.
|
||||||
|
|
||||||
|
The initial safety metadata contract lives in
|
||||||
|
`govoplan_core.core.configuration_safety`. It classifies known configuration
|
||||||
|
fields as UI-managed or deployment-managed, assigns risk levels, marks secret
|
||||||
|
handling as reference-only or env-only, and declares dry-run, policy
|
||||||
|
explanation, audit, approval, rollback-history, maintenance-mode, and RBAC
|
||||||
|
requirements. Admin UI editors should consume this metadata before exposing
|
||||||
|
powerful settings.
|
||||||
|
|
||||||
|
The initial executable guardrail path is `plan_configuration_change(...)`,
|
||||||
|
exposed through:
|
||||||
|
|
||||||
|
- `GET /api/v1/admin/configuration-safety`
|
||||||
|
- `POST /api/v1/admin/configuration-safety/plan`
|
||||||
|
|
||||||
|
The planner reports missing scopes, dry-run requirements, maintenance-mode
|
||||||
|
requirements, two-person approval status, secret-reference violations,
|
||||||
|
rollback-history requirements, policy explanations, and audit event names before
|
||||||
|
an editor applies a high-impact configuration change.
|
||||||
|
|
||||||
|
## Reference 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
|
||||||
|
|
||||||
|
Campaign is the first complete proof of modular composition. Campaign owns
|
||||||
|
intent, recipient snapshots, personalization, execution state, and delivery
|
||||||
|
evidence. Mail owns reusable profiles, credentials, protocol policy, and
|
||||||
|
provider execution; Campaign stores only a selected profile reference. Files
|
||||||
|
owns storage, connector profiles, file policy, and provenance.
|
||||||
|
|
||||||
|
The technical gate is a pinned Campaign/Mail/Files composition with central
|
||||||
|
UI, adaptive user/admin/operator/integration documentation, target SMTP/IMAP
|
||||||
|
and file-provider evidence, and explicit test/send/resend/retry/reconciliation
|
||||||
|
semantics. Readers must not receive backend paths, worker claims, secrets, or
|
||||||
|
raw provider diagnostics.
|
||||||
|
|
||||||
|
### Journey 2: Function-Bound Postbox Delivery
|
||||||
|
|
||||||
|
Postbox accepts delivery to an addressable postbox or a function in an
|
||||||
|
organizational unit. Organizations owns units and functions, Identity owns
|
||||||
|
subjects, IDM owns identity-to-function assignments and upstream sync, and
|
||||||
|
Access resolves current roles, delegation, acting context, and permission.
|
||||||
|
|
||||||
|
Campaign consumes a typed delivery-target capability without importing Postbox
|
||||||
|
or identity internals. Reassignment changes future access without moving the
|
||||||
|
message; vacancy or ambiguous acting context fails visibly; delivery, access,
|
||||||
|
and correction remain auditable.
|
||||||
|
|
||||||
|
### Journey 3: Data-Backed Templates, Reports, And Deep Launch
|
||||||
|
|
||||||
|
An authenticated user follows an opaque, short-lived launch reference from HIS
|
||||||
|
or another specialist system. GovOPlaN re-authorizes the actor, resolves a
|
||||||
|
curated data context server-side, displays source/freshness/version, and renders
|
||||||
|
one reproducible document and report.
|
||||||
|
|
||||||
|
Templates owns definition/version/schema/rendering, Reporting owns source
|
||||||
|
selection/parameters/execution/export, Files owns generated bytes, and
|
||||||
|
connectors own protocol access. URLs do not carry credentials, arbitrary SQL,
|
||||||
|
or trusted raw personal data. Retries are idempotent and generation evidence
|
||||||
|
connects source, snapshot/reference, transformation, definition, parameters,
|
||||||
|
output checksum, actor, and policy.
|
||||||
|
|
||||||
|
### Journey 4: Governed University BI Path
|
||||||
|
|
||||||
|
Starting from the Journey 3 source contract, one bounded university dataset is
|
||||||
|
catalogued, staged by snapshot or watermark, validated, transformed through a
|
||||||
|
versioned lineage graph, and exposed as a policy-aware analytical data product.
|
||||||
|
The result must preserve official-key mappings, organizational and reporting
|
||||||
|
date semantics, quality findings, quarantine/replay, transparent calculation,
|
||||||
|
and reproducible promotion between development, test, and production.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
An uploaded or generated artifact becomes a DMS document. Files continues to
|
||||||
|
own bytes; DMS owns identity, versions, renditions, editing sessions, locks,
|
||||||
|
comments, review, approval, comparison, and recovery; a collaboration connector
|
||||||
|
owns provider-specific protocol behavior; Records owns later classification,
|
||||||
|
hold, archive, and disposal.
|
||||||
|
|
||||||
|
The gate requires no silent lost updates, short-lived and currently authorized
|
||||||
|
editing sessions, idempotent authenticated callbacks, visible uncertain saves,
|
||||||
|
immutable accepted renditions, and a Records-ready handoff with stable content
|
||||||
|
and provenance.
|
||||||
|
|
||||||
|
## Capability Dependency Waves
|
||||||
|
|
||||||
|
The waves below remain a dependency and ownership catalogue for the wider
|
||||||
|
product vision. They are not the active delivery order. The five selected
|
||||||
|
journeys above and the meta roadmap decide what is implemented now; other
|
||||||
|
clusters remain dormant until a selected journey consumes them or they are
|
||||||
|
explicitly reprioritized.
|
||||||
|
|
||||||
|
### Wave 0: Platform Spine
|
||||||
|
|
||||||
|
Goal: make the platform safe to configure and extend.
|
||||||
|
|
||||||
|
Refine:
|
||||||
|
|
||||||
|
- `govoplan-core`: module discovery, capabilities, events, migrations, release
|
||||||
|
catalog, configuration package runtime, WebUI shell.
|
||||||
|
- `govoplan-identity`: canonical identities and account links.
|
||||||
|
- `govoplan-organizations`: organizational structures, units, and functions.
|
||||||
|
- `govoplan-idm`: identity-to-function assignments, directory synchronization,
|
||||||
|
preview, conflicts, and reconciliation.
|
||||||
|
- `govoplan-access`: sessions, API keys, users, groups, roles, memberships,
|
||||||
|
function-to-role projection, delegation, acting context, and RBAC decisions.
|
||||||
|
- `govoplan-tenancy`: tenant lifecycle and tenant boundaries.
|
||||||
|
- `govoplan-identity-trust`: initial trust contracts for device keys, public key
|
||||||
|
directory, assurance, and later encrypted postbox key access.
|
||||||
|
- `govoplan-policy`: policy sources, policy decisions, retention inputs.
|
||||||
|
- `govoplan-audit`: audit sink, audit routes, trace context, retention
|
||||||
|
cooperation.
|
||||||
|
- `govoplan-admin`: UI-managed configuration with preflight, rollback, audit,
|
||||||
|
and approval controls.
|
||||||
|
- `govoplan-dashboard`: configurable user home assembled from module-provided
|
||||||
|
widgets, with core providing only a minimal fallback when the module is absent.
|
||||||
|
- `govoplan-docs`: configured-system documentation and evidence-aware help.
|
||||||
|
- `govoplan-ops`: deployment profiles, health checks, worker split, sizing
|
||||||
|
assumptions.
|
||||||
|
|
||||||
|
Do not expand domain scope in this wave. The output is a dependable platform
|
||||||
|
surface for later modules.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- module enablement and capability lookup are stable
|
||||||
|
- configuration package preflight works for at least one simple package
|
||||||
|
- audit and policy decisions are visible in admin flows
|
||||||
|
- access distinguishes identity, account, function, role, and right in durable
|
||||||
|
contracts
|
||||||
|
- action/effect automation contracts are specified before hidden side effects
|
||||||
|
spread across modules
|
||||||
|
- docs can show installed/enabled modules and configured routes
|
||||||
|
|
||||||
|
### Wave 1: Permit-To-Payment MVP
|
||||||
|
|
||||||
|
Goal: one complete public-administration process.
|
||||||
|
|
||||||
|
Create or refine in this order:
|
||||||
|
|
||||||
|
1. `govoplan-forms-runtime`: form submissions, drafts, validation, attachments,
|
||||||
|
and submission evidence.
|
||||||
|
2. `govoplan-portal`: public authenticated and unauthenticated entry points.
|
||||||
|
3. `govoplan-files`: upload, evidence links, file spaces, and permissioned
|
||||||
|
access.
|
||||||
|
4. `govoplan-cases`: case record, status, assignments, deadlines, and case
|
||||||
|
evidence.
|
||||||
|
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
|
||||||
|
internal communication, with the postbox model compatible with later E2EE
|
||||||
|
and role-bound access.
|
||||||
|
9. `govoplan-calendar`, `govoplan-appointments`, `govoplan-booking`: appointment
|
||||||
|
or booking handoff for the reference process.
|
||||||
|
10. `govoplan-payments`, `govoplan-ledger`, `govoplan-xrechnung`: payment
|
||||||
|
capture, payment evidence, and accounting/e-invoice handoff.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- a permit-to-payment package can be installed in a local demo
|
||||||
|
- every step has audit evidence
|
||||||
|
- every module integration uses capabilities, events, or DTOs
|
||||||
|
- user-facing documentation describes only the configured process
|
||||||
|
|
||||||
|
### Wave 2: Booking And Resource Operations
|
||||||
|
|
||||||
|
Goal: make time, capacity, and resource allocation a reusable product area.
|
||||||
|
|
||||||
|
Create or refine in this order:
|
||||||
|
|
||||||
|
1. `govoplan-booking`: booking rules, capacity, quotas, waitlists, cancellation,
|
||||||
|
attendance, no-shows, and approvals.
|
||||||
|
2. `govoplan-resources`: rooms, equipment, vehicles, counters, labs, capacity,
|
||||||
|
availability, and maintenance blocks.
|
||||||
|
3. `govoplan-calendar`: event and availability integration.
|
||||||
|
4. `govoplan-appointments`: appointment-specific booking surfaces.
|
||||||
|
5. `govoplan-facilities`: buildings, rooms, maintenance, access zones,
|
||||||
|
inspections, and defects.
|
||||||
|
6. `govoplan-assets`: inventory, assignment, lifecycle, maintenance, and handover
|
||||||
|
evidence.
|
||||||
|
|
||||||
|
Reference journey: reserve a room/resource for a training or appointment and
|
||||||
|
preserve all booking evidence.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- bookable resources are not hardcoded into appointments
|
||||||
|
- booking decisions are explainable and auditable
|
||||||
|
- resources can be blocked by maintenance or facility status
|
||||||
|
|
||||||
|
### Wave 3: Training And Certificates
|
||||||
|
|
||||||
|
Goal: support university-style and internal public-sector training without
|
||||||
|
building a full learning-management system first.
|
||||||
|
|
||||||
|
Create or refine in this order:
|
||||||
|
|
||||||
|
1. `govoplan-learning`: course planning, course booking, participant lists,
|
||||||
|
trainers, attendance, evaluations, and learning records.
|
||||||
|
2. `govoplan-certificates`: participation confirmations, certificates,
|
||||||
|
verification, revocation, and evidence links.
|
||||||
|
3. `govoplan-booking`, `govoplan-resources`, `govoplan-calendar`: reuse booking
|
||||||
|
and room/resource allocation.
|
||||||
|
4. `govoplan-templates`, `govoplan-files`, `govoplan-records`: certificate
|
||||||
|
generation and durable retention.
|
||||||
|
|
||||||
|
Reference journey: plan a course, book resources, register participants, track
|
||||||
|
attendance, and issue a certificate.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- attendance can produce certificate eligibility
|
||||||
|
- certificates can be verified later
|
||||||
|
- course operations do not depend on university-specific assumptions
|
||||||
|
|
||||||
|
### Wave 4: Report-To-Resolution Operations
|
||||||
|
|
||||||
|
Goal: cover internal support and public issue reporting.
|
||||||
|
|
||||||
|
Create or refine in this order:
|
||||||
|
|
||||||
|
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.
|
||||||
|
3. `govoplan-facilities` and `govoplan-assets`: issue handoff to maintenance or
|
||||||
|
asset lifecycle.
|
||||||
|
4. `govoplan-cases`, `govoplan-workflow`, `govoplan-tasks`: escalation into
|
||||||
|
formal administrative matters.
|
||||||
|
5. `govoplan-reporting`: workload, SLA, recurring defects, and status reports.
|
||||||
|
|
||||||
|
Reference journey: report a facility issue, triage it, assign work, resolve it,
|
||||||
|
and report recurring defects.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- issue reporting is not just a generic form inbox
|
||||||
|
- helpdesk tickets can remain lightweight unless formal case handling is needed
|
||||||
|
- operational metrics exist without custom SQL
|
||||||
|
|
||||||
|
### Wave 5: Records, DMS, Transparency
|
||||||
|
|
||||||
|
Goal: make evidence legally and organizationally durable.
|
||||||
|
|
||||||
|
Create or refine in this order:
|
||||||
|
|
||||||
|
1. `govoplan-dms`: document lifecycle, collaboration, versions, locks, approvals,
|
||||||
|
and DMS connectors.
|
||||||
|
2. `govoplan-records`: file plans, classification, retention schedules, disposal
|
||||||
|
holds, archive handoff, and records evidence.
|
||||||
|
3. `govoplan-policy` and `govoplan-audit`: retention policy, legal hold, audit
|
||||||
|
traceability.
|
||||||
|
4. `govoplan-search`: permissioned cross-module discovery.
|
||||||
|
5. `govoplan-transparency`: FOI/public-records requests, redaction, publication,
|
||||||
|
and disclosure evidence.
|
||||||
|
|
||||||
|
Reference journey: close a case, classify records, apply retention, and answer a
|
||||||
|
transparency request.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- files, documents, and records are not treated as the same concept
|
||||||
|
- transparency disclosure can redact and explain evidence
|
||||||
|
- retention and archive handoff are policy-governed
|
||||||
|
|
||||||
|
### Wave 6: Finance, Procurement, Contracts, Grants
|
||||||
|
|
||||||
|
Goal: cover the back-office procedures around spending, obligations, funding,
|
||||||
|
and revenue without replacing a finance system too early.
|
||||||
|
|
||||||
|
Create or refine in this order:
|
||||||
|
|
||||||
|
1. `govoplan-procurement`: purchase requests, approvals, vendor comparison,
|
||||||
|
tender references, goods receipt, and handoff.
|
||||||
|
2. `govoplan-contracts`: contract register, obligations, renewals, reminders,
|
||||||
|
and responsible units.
|
||||||
|
3. `govoplan-grants`: funding programs, applications, eligibility, awards,
|
||||||
|
milestones, claims, and reporting duties.
|
||||||
|
4. `govoplan-payments`, `govoplan-ledger`, `govoplan-xrechnung`: payment,
|
||||||
|
accounting, and e-invoice handoff.
|
||||||
|
5. `govoplan-erp`: integration with the actual system of record, not a full ERP
|
||||||
|
replacement unless later justified.
|
||||||
|
|
||||||
|
Reference journey: purchase or grant approval through obligation tracking and
|
||||||
|
financial handoff.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- ERP remains an integration point unless GovOPlaN must own semantics
|
||||||
|
- contracts and grants produce obligations, deadlines, and reporting tasks
|
||||||
|
- financial evidence links back to cases, files, and audit
|
||||||
|
|
||||||
|
### Wave 7: Institutional Governance And Participation
|
||||||
|
|
||||||
|
Goal: support public bodies, municipalities, ministries, and universities in
|
||||||
|
formal coordination.
|
||||||
|
|
||||||
|
Create or refine in this order:
|
||||||
|
|
||||||
|
1. `govoplan-committee`: committees, boards, councils, agendas, minutes,
|
||||||
|
decisions, voting, and follow-up tasks.
|
||||||
|
2. `govoplan-consultation`: hearings, public consultations, stakeholder
|
||||||
|
feedback, formal comments, response matrices, and publication workflows.
|
||||||
|
3. `govoplan-campaign`: communication campaigns, recipients, templates, review,
|
||||||
|
sending control, and reports.
|
||||||
|
4. `govoplan-addresses`: persons, organizations, contacts, distribution lists,
|
||||||
|
and recipient import/export.
|
||||||
|
5. `govoplan-portal` and `govoplan-transparency`: public participation and
|
||||||
|
publication surfaces.
|
||||||
|
|
||||||
|
Reference journey: prepare a committee decision, publish consultation material,
|
||||||
|
collect feedback, document the decision, and assign follow-up tasks.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- decisions create durable tasks and records
|
||||||
|
- consultations can publish evidence without exposing restricted material
|
||||||
|
- campaign and address behavior stays optional and capability-based
|
||||||
|
|
||||||
|
### Wave 8: Integration, Data, And Reporting
|
||||||
|
|
||||||
|
Goal: connect the platform to real institutional landscapes.
|
||||||
|
|
||||||
|
Refine:
|
||||||
|
|
||||||
|
- `govoplan-connectors`: integration catalog, connector metadata, adapter
|
||||||
|
lifecycle, test harnesses.
|
||||||
|
- `govoplan-idm`, `govoplan-identity-trust`: identity provider, directory, and
|
||||||
|
assurance integration.
|
||||||
|
- `govoplan-fit-connect`, `govoplan-xoev`, `govoplan-xta-osci`: public-sector
|
||||||
|
transport and standards integration.
|
||||||
|
- `govoplan-reporting`: operational reporting, scheduled outputs, exports, and
|
||||||
|
dashboard data.
|
||||||
|
- `govoplan-search`: permissioned cross-module discovery.
|
||||||
|
|
||||||
|
Refine the existing owners:
|
||||||
|
|
||||||
|
- `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 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
|
||||||
|
- datasource and dataflow ownership remains provider-neutral and is proved by
|
||||||
|
the recurring transformation package
|
||||||
|
- reporting consumes governed sources with provenance
|
||||||
|
|
||||||
|
## Implementation Gates
|
||||||
|
|
||||||
|
Before a module receives implementation work, it needs:
|
||||||
|
|
||||||
|
- one reference journey step it owns
|
||||||
|
- ownership and boundary notes
|
||||||
|
- initial manifest and permission list
|
||||||
|
- capability contracts or API DTOs
|
||||||
|
- audit and policy expectations
|
||||||
|
- Gitea issues for MVP tasks
|
||||||
|
- docs page that can sync to the wiki
|
||||||
|
- focused tests that can run from the core environment
|
||||||
|
|
||||||
|
Before a module becomes release-included, it needs:
|
||||||
|
|
||||||
|
- installable Python package metadata where applicable
|
||||||
|
- WebUI package metadata where applicable
|
||||||
|
- module manifest entry point
|
||||||
|
- release catalog entry
|
||||||
|
- smoke test or permutation test
|
||||||
|
- no required imports from optional modules
|
||||||
|
|
||||||
|
## Technical Dependency Order Summary
|
||||||
|
|
||||||
|
Use this active order while respecting the ownership and implementation gates
|
||||||
|
in the capability waves:
|
||||||
|
|
||||||
|
1. Keep the platform/release spine green and extend connector, identity,
|
||||||
|
external-effect, provenance, documentation, focused-view, recovery, and
|
||||||
|
version contracts only as the current journey requires.
|
||||||
|
2. Complete and package Campaign with Mail-owned profiles, Files, target
|
||||||
|
delivery/recovery, central UI, and adaptive documentation.
|
||||||
|
3. Implement function-bound Postbox delivery through
|
||||||
|
Organizations–Identity–IDM–Access and consume it from Campaign through a
|
||||||
|
typed capability.
|
||||||
|
4. Implement one data-backed Templates/Reporting path and safe HIS-style deep
|
||||||
|
launch.
|
||||||
|
5. Extend that concrete source into one governed university analytical data
|
||||||
|
product and use it to harden the existing Datasources/Dataflow ownership,
|
||||||
|
quality, lineage, and promotion contracts.
|
||||||
|
6. Implement Files-backed DMS versions and one provider-neutral collaborative
|
||||||
|
editing lifecycle, then connect Records handoff.
|
||||||
|
7. Maintain already integrated Calendar/Scheduling/Poll and other foundations;
|
||||||
|
activate another capability cluster only when the current journey needs it
|
||||||
|
or the product roadmap explicitly reprioritizes it.
|
||||||
|
8. Extend Workflow Engine and the optional editor only through stable actions
|
||||||
|
and one demonstrated package at a time.
|
||||||
|
|
||||||
|
## Deliberate Deferrals
|
||||||
|
|
||||||
|
Defer these until a reference journey proves the need:
|
||||||
|
|
||||||
|
- full ERP replacement
|
||||||
|
- 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
|
||||||
|
encrypted-postbox contracts needed for early architecture safety
|
||||||
|
- mobile apps
|
||||||
|
- AI assistants embedded into workflows
|
||||||
|
|
||||||
|
These may become important, but they should not distract from the first complete
|
||||||
|
administrative journeys.
|
||||||
|
|
||||||
|
## Module And Integration Routing
|
||||||
|
|
||||||
|
This table maps current module and integration ideas to existing GovOPlaN
|
||||||
|
repositories or to explicit missing-module decisions.
|
||||||
|
|
||||||
|
| Idea | Owner | Tracking |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 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; 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 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 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
|
||||||
|
ownership, and prioritization rules. `govoplan-connectors` owns the detailed
|
||||||
|
target inventory and connector entry shape in
|
||||||
|
`govoplan-connectors/docs/PUBLIC_SECTOR_INTEGRATION_CATALOGUE.md`.
|
||||||
|
|
||||||
|
Release composition and tag-only repository handling are documented in
|
||||||
|
`RELEASE_DEPENDENCIES.md`.
|
||||||
|
|
||||||
|
## Next Practical Work
|
||||||
|
|
||||||
|
The active cross-product story is
|
||||||
|
[`GovOPlaN/govoplan#14`](https://git.add-ideas.de/GovOPlaN/govoplan/issues/14).
|
||||||
|
Module repositories own implementation issues; do not clone their state here.
|
||||||
|
|
||||||
|
Immediate issue buckets:
|
||||||
|
|
||||||
|
- fail-closed connector destination pinning and private-network deployment
|
||||||
|
control for every real transport
|
||||||
|
- Mail-profile-only Campaign authoring/build/delivery and safe legacy failure
|
||||||
|
- immediate audited secret deletion when a provider/profile is removed
|
||||||
|
- Campaign central-component and role-safe UI acceptance
|
||||||
|
- adaptive Campaign, Mail, and Files task/process/admin/operator/integration/
|
||||||
|
security/acceptance documentation
|
||||||
|
- target SMTP/IMAP, file-provider, queue/reconciliation, install/upgrade, and
|
||||||
|
restore proof for the pinned Campaign reference composition
|
||||||
|
|
||||||
|
Once that gate is demonstrable, activate the existing Postbox model, access,
|
||||||
|
API, inbox, and Campaign integration issues. Templates/Reporting, governed BI,
|
||||||
|
and DMS collaboration remain durable selected direction, but should be
|
||||||
|
decomposed only as the preceding stage stabilizes or a bounded independent
|
||||||
|
contract can be implemented without pre-deciding target-system choices.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# GovOPlaN Interface Ethics And Design Doctrine
|
||||||
|
|
||||||
|
This document captures the product-level design doctrine for GovOPlaN. It is
|
||||||
|
more durable than an individual screen design and should guide admin,
|
||||||
|
configuration, workflow, policy, portal, and operational UI decisions.
|
||||||
|
|
||||||
|
GovOPlaN is meant to support administrative responsibility. The interface must
|
||||||
|
therefore make context, decision, consequence, and traceability visible enough
|
||||||
|
that users can act deliberately instead of being pushed through opaque
|
||||||
|
automation.
|
||||||
|
|
||||||
|
## Core Doctrine
|
||||||
|
|
||||||
|
1. Context, decision, and consequence belong together.
|
||||||
|
2. Decisions should not silently happen.
|
||||||
|
3. Transparency comes before convenience when rights, duties, records, money,
|
||||||
|
access, or legal effects are involved.
|
||||||
|
4. Explicit state is preferable to implicit state.
|
||||||
|
5. Navigation is not consent.
|
||||||
|
6. Responsibility cannot be delegated to the system.
|
||||||
|
7. Traceability is part of the action, not a later reporting feature.
|
||||||
|
8. Context loss is a product defect.
|
||||||
|
|
||||||
|
These rules do not mean every screen should become verbose. They mean the
|
||||||
|
interface must expose the right explanation at the moment of decision and keep
|
||||||
|
technical detail available without making it the default surface.
|
||||||
|
|
||||||
|
## Decision Surface Contract
|
||||||
|
|
||||||
|
Any action that changes records, rights, policies, retention, communication,
|
||||||
|
payments, external systems, or workflow state should answer these questions
|
||||||
|
before execution:
|
||||||
|
|
||||||
|
- What object, person, organization, or process is affected?
|
||||||
|
- Which authority or role allows the actor to do this?
|
||||||
|
- What will change immediately?
|
||||||
|
- What downstream effects may happen?
|
||||||
|
- Can the action be undone, superseded, or only corrected later?
|
||||||
|
- What evidence or audit entry will be created?
|
||||||
|
- Which policy, configuration, or missing capability blocks the action?
|
||||||
|
- Who can resolve a blocker?
|
||||||
|
|
||||||
|
The answer may be shown through inline labels, a review step, a side panel, a
|
||||||
|
problem list, or a confirmation dialog. The important point is that consequence
|
||||||
|
and responsibility are not hidden behind a generic submit button.
|
||||||
|
|
||||||
|
## Contestability
|
||||||
|
|
||||||
|
Administrative decisions are often contestable or reviewable. GovOPlaN should
|
||||||
|
therefore preserve the path from input to decision:
|
||||||
|
|
||||||
|
- source data and attachments
|
||||||
|
- workflow state and task assignment
|
||||||
|
- policy decisions and source path
|
||||||
|
- actor and delegation context
|
||||||
|
- generated document/template version
|
||||||
|
- external handoff result
|
||||||
|
- notification or postbox delivery evidence
|
||||||
|
- retention and record classification state
|
||||||
|
|
||||||
|
Where a user sees a decision, they should be able to reach the provenance that
|
||||||
|
explains how the system got there. This is especially important for denials,
|
||||||
|
locks, calculated defaults, generated documents, payment state, retention
|
||||||
|
state, and access decisions.
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
Avoid these patterns in GovOPlaN interfaces:
|
||||||
|
|
||||||
|
- Magical buttons that execute multiple side effects without preview.
|
||||||
|
- Process tunnels that hide where the user is in an administrative procedure.
|
||||||
|
- Silent automation that changes external systems without an audit-visible
|
||||||
|
command record.
|
||||||
|
- Friendly hiding that removes complexity at the cost of obscuring authority,
|
||||||
|
consequence, or accountability.
|
||||||
|
- Disabled controls without actionable explanation.
|
||||||
|
- Configuration screens that ask users to edit raw JSON as the normal path.
|
||||||
|
|
||||||
|
## Automation Rule
|
||||||
|
|
||||||
|
Automation must use the same governed action surface as a human actor. The
|
||||||
|
system may execute actions as a system actor, but it must still run through
|
||||||
|
policy checks, capability contracts, audit, idempotency, and failure handling.
|
||||||
|
|
||||||
|
When an automated decision is not clear, GovOPlaN should create a manual
|
||||||
|
exception, task, or review item instead of guessing silently.
|
||||||
|
|
||||||
|
## Relationship To UI Components
|
||||||
|
|
||||||
|
Shared components should make this doctrine easy to follow:
|
||||||
|
|
||||||
|
- preflight and problem-list components for blockers
|
||||||
|
- policy source path and effective decision displays
|
||||||
|
- action review panels for consequence preview
|
||||||
|
- audit/provenance links on decision outputs
|
||||||
|
- guided dialogs for risky configuration
|
||||||
|
- disabled-action explanations with actor and next step
|
||||||
|
- confirmation dialogs that distinguish reversible, corrective, and destructive
|
||||||
|
actions
|
||||||
|
|
||||||
|
The UI/UX decision ledger defines concrete implementation rules. This doctrine
|
||||||
|
defines why those rules exist.
|
||||||
@@ -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 1–4 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.
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Structured Documentation Localization
|
||||||
|
|
||||||
|
`DocumentationTopic.translations` continues to own localized title, summary,
|
||||||
|
and body prose. Topics whose metadata contains rendered prose opt into the
|
||||||
|
separate `structured_translation_version="1"` contract and provide a complete
|
||||||
|
same-shape value for each translated metadata key in
|
||||||
|
`structured_translations`. Version 1 covers workflow prerequisites, steps,
|
||||||
|
outcome, result and verification; reference fields; limitations, constraints,
|
||||||
|
consequences and consequence classes; and the other rendered explanation
|
||||||
|
fields declared by Core.
|
||||||
|
|
||||||
|
The registry rejects an unversioned translation, an unsupported contract
|
||||||
|
version, missing structured keys, changed object keys or list lengths, empty
|
||||||
|
translated strings, and changed non-text values. Stable field IDs, routes,
|
||||||
|
permission scopes, and other technical leaves therefore remain structurally
|
||||||
|
bound to the source metadata. The Docs module overlays only a validated locale
|
||||||
|
at response time and reports the selected structured locale separately from the
|
||||||
|
title/body locale. Missing structured translations fall back to source content
|
||||||
|
and remain visible in public coverage until the owning module adopts the
|
||||||
|
contract.
|
||||||
|
|
||||||
|
## 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 same inventory classifies controls whose labels, identities, component
|
||||||
|
context, or explicit `data-help-risk` indicate authority, credentials,
|
||||||
|
disclosure, encryption, external effects, irreversible changes, policy, or
|
||||||
|
retention. These controls require an exact context rather than relying only on
|
||||||
|
page fallback. Reviewed false positives carry
|
||||||
|
`data-help-risk-reviewed="standard"`. Invalid risk classes and any increase
|
||||||
|
above the versioned `tools/inventory/high-risk-help-baseline.json` ceiling fail
|
||||||
|
strict declaration checks; the ceiling is lowered as the finite queue is
|
||||||
|
resolved. Password fields and their generator dialog propagate the owning
|
||||||
|
field's context so shared credential controls never invent a Core-owned topic.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
The same conformance suite mounts the production Forms Runtime self-service and
|
||||||
|
assisted Anwohnerparkausweis surfaces with German module translations. Desktop
|
||||||
|
and mobile runs traverse native controls by keyboard, inspect accessible names
|
||||||
|
and landmarks, run WCAG 2.1 A/AA automation, verify responsive overflow, and
|
||||||
|
retain independent per-field assisted provenance. Physical assistive-technology
|
||||||
|
spot checks remain release evidence rather than being represented as browser
|
||||||
|
automation.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- no invalid high-risk help annotation or regression above the recorded
|
||||||
|
exact-context debt ceiling.
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
+1531
-19
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
# GovOPlaN Policy Contracts
|
||||||
|
|
||||||
|
GovOPlaN has several policy families that are moving out of core into owning
|
||||||
|
modules. The shared kernel contract keeps their decision and provenance shape
|
||||||
|
consistent while each module still owns its domain rules.
|
||||||
|
|
||||||
|
## Current Policy Inventory
|
||||||
|
|
||||||
|
| Policy area | Current owner | Runtime surface | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Privacy retention | `govoplan-policy` implementation and routes, with compatibility helpers in core | `/api/v1/admin/privacy-retention/policies/{scope}` and `/explain`; capability `policy.privacyRetention` | System, tenant, user, group, and campaign sources merge into the effective retention policy. Parent locks block lower-level widening. |
|
||||||
|
| Mail profile policy | `govoplan-mail` | `/api/v1/mail/policies/{scope}` | Uses the same source-step path format for system, tenant, owner, and campaign provenance. |
|
||||||
|
| 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
|
||||||
|
|
||||||
|
The shared DTO lives in `govoplan_core.core.policy.PolicyDecision`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"allowed": false,
|
||||||
|
"reason": "Parent retention policy locks lower-level changes.",
|
||||||
|
"source_path": [
|
||||||
|
{
|
||||||
|
"scope_type": "system",
|
||||||
|
"scope_id": null,
|
||||||
|
"path": "system",
|
||||||
|
"label": "System",
|
||||||
|
"applied_fields": ["allow_lower_level_limits"],
|
||||||
|
"policy": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requirements": ["raw_campaign_json_retention_days"],
|
||||||
|
"details": {
|
||||||
|
"blocked_fields": ["raw_campaign_json_retention_days"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`allowed` is the effective answer for the checked action. `reason` is a stable,
|
||||||
|
human-readable summary. `source_path` lists the policy sources that explain the
|
||||||
|
answer. `requirements` lists machine-readable blockers or prerequisites, and
|
||||||
|
`details` carries domain-specific structured context.
|
||||||
|
|
||||||
|
Every source step should be concrete enough for an operator to understand the
|
||||||
|
decision without knowing internal merge rules. Use real scope labels such as
|
||||||
|
`System`, `Tenant`, `Owner user`, `Group`, or a campaign/profile name. Include
|
||||||
|
the stable `path`, the fields applied by that step, and the local policy
|
||||||
|
fragment that caused them. This lets UIs render explanations like
|
||||||
|
`System: Allow > Tenant: Deny without override` without additional lookups.
|
||||||
|
If a policy family cannot expose the full local fragment for security reasons,
|
||||||
|
it must still include a redacted structured value that identifies the applied
|
||||||
|
field and the effective allow/deny or lock state.
|
||||||
|
|
||||||
|
## Source Path Format
|
||||||
|
|
||||||
|
Policy source paths are stable string identifiers for provenance steps:
|
||||||
|
|
||||||
|
- `system`
|
||||||
|
- `<scope_type>:<url-encoded-scope-id>`
|
||||||
|
|
||||||
|
Supported scope types are `system`, `tenant`, `user`, `group`, and `campaign`.
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- `tenant:4a45b4fe-1d86-43ce-9d10-6022333f4d4b`
|
||||||
|
- `campaign:campaign%2Fwith%20space`
|
||||||
|
|
||||||
|
Use `policy_source_path()` and `parse_policy_source_path()` instead of building
|
||||||
|
or splitting these strings manually.
|
||||||
|
|
||||||
|
## Retention Explain Endpoint
|
||||||
|
|
||||||
|
`GET /api/v1/admin/privacy-retention/policies/{scope_type}/explain` returns:
|
||||||
|
|
||||||
|
- `scope_type` and optional `scope_id`
|
||||||
|
- `decision`, using the shared `PolicyDecision` shape
|
||||||
|
- `effective_policy`
|
||||||
|
- optional `parent_policy`
|
||||||
|
- `effective_policy_sources`
|
||||||
|
- `parent_policy_sources`
|
||||||
|
- `blocked_fields`
|
||||||
|
|
||||||
|
The endpoint is read-only. Enforcement remains in the existing policy write
|
||||||
|
path. For lower-level scopes, `blocked_fields` is derived from the parent
|
||||||
|
policy's `allow_lower_level_limits`; clients can use it to disable local
|
||||||
|
controls before attempting a write.
|
||||||
|
|
||||||
|
The retention implementation lives in `govoplan-policy`
|
||||||
|
(`govoplan_policy.backend.retention`). Core keeps
|
||||||
|
`govoplan_core.privacy.retention` only as a compatibility facade for older
|
||||||
|
imports. Effective/scoped retention behavior dispatches through the
|
||||||
|
`policy.privacyRetention` capability; core does not import policy implementation
|
||||||
|
code as a hidden fallback when the module is disabled or no runtime is active.
|
||||||
|
New backend code should import policy-owned retention behavior from
|
||||||
|
`govoplan-policy` or request the capability, not add new implementation logic
|
||||||
|
to core.
|
||||||
|
|
||||||
|
The retention API DTOs live in `govoplan_core.privacy.schemas`.
|
||||||
|
`PrivacyRetentionPolicyItem`, `PrivacyRetentionPolicyPatchItem`,
|
||||||
|
`RETENTION_POLICY_FIELD_KEYS`, and `default_allow_lower_level_limits()` are
|
||||||
|
platform contracts because admin, access compatibility, and policy routes expose
|
||||||
|
the same stable retention payload shape. The policy engine's internal
|
||||||
|
`PrivacyRetentionPolicy` and `PrivacyRetentionPolicyPatch` models stay in
|
||||||
|
`govoplan-policy`, because they carry implementation validators and merge
|
||||||
|
behavior that are not generic API contracts.
|
||||||
|
|
||||||
|
Tenant administration DTOs remain owned by `govoplan-tenancy`; access keeps
|
||||||
|
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:
|
||||||
|
|
||||||
|
- render effective source provenance when `effective_policy_sources` is present
|
||||||
|
- display a field-level path when the source data is shown next to a specific
|
||||||
|
setting, using concrete source labels and stop at the first non-overridable
|
||||||
|
deny/lock
|
||||||
|
- disable local field controls when the parent policy sets that field's
|
||||||
|
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
|
||||||
|
tests.
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
# Postbox End-To-End Encryption Architecture
|
||||||
|
|
||||||
|
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.
|
||||||
|
The server may route, store, authorize, audit, retain, and expire messages while
|
||||||
|
message bodies and attachments remain client-encrypted.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- asynchronous encrypted delivery for internal and portal-facing postboxes
|
||||||
|
- personal, organizational, role-bound, and function-bound postboxes
|
||||||
|
- attachments encrypted with the message
|
||||||
|
- access based on current role/function membership when configured
|
||||||
|
- honest retraction and expiry semantics
|
||||||
|
- auditable key access, delivery, and fetch events
|
||||||
|
- support for external recipients without platform accounts
|
||||||
|
- replaceable identity and trust providers
|
||||||
|
|
||||||
|
## Envelope Model
|
||||||
|
|
||||||
|
The target model is envelope encryption:
|
||||||
|
|
||||||
|
- Generate one random data encryption key per message or attachment set.
|
||||||
|
- Encrypt content with an authenticated encryption algorithm.
|
||||||
|
- Wrap the data encryption key for each authorized recipient or role mailbox.
|
||||||
|
- Store only ciphertext, wrapped keys, signed manifests, and governed metadata
|
||||||
|
on the server.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
- account identity
|
||||||
|
- tenant membership
|
||||||
|
- role/function assignment
|
||||||
|
- device key
|
||||||
|
- postbox binding
|
||||||
|
|
||||||
|
Identity providers and directories can authenticate users and provide membership
|
||||||
|
facts, but they must not see postbox private keys or message plaintext.
|
||||||
|
|
||||||
|
The trust layer should provide:
|
||||||
|
|
||||||
|
- public key directory
|
||||||
|
- account or identity signing keys
|
||||||
|
- per-device encryption keys
|
||||||
|
- device registration and revocation
|
||||||
|
- key rotation and epoch tracking
|
||||||
|
- recovery policy hooks
|
||||||
|
|
||||||
|
Recovery must be organizationally governed. A server-held universal plaintext
|
||||||
|
key would defeat the E2EE claim; any escrow, threshold recovery, or emergency
|
||||||
|
grant needs an explicit assurance profile, authority/quorum, audit trail, and
|
||||||
|
user-visible consequence.
|
||||||
|
|
||||||
|
## Role And Function Postboxes
|
||||||
|
|
||||||
|
Role-bound access needs special handling. A postbox can be bound to an
|
||||||
|
organizational unit and a role or function. Current members can access current
|
||||||
|
messages according to policy; former members should lose access to not-yet
|
||||||
|
fetched material when revocation is still technically enforceable.
|
||||||
|
|
||||||
|
The target design should support role encryption keys or an equivalent
|
||||||
|
rewrapping service:
|
||||||
|
|
||||||
|
- sender encrypts the content key for the role/function postbox
|
||||||
|
- access service verifies current membership and required assurance
|
||||||
|
- trust service rewraps the content key to the actor's current device key
|
||||||
|
- audit records the key access decision and fetch event
|
||||||
|
|
||||||
|
Key epochs are required when role membership changes. Older messages may remain
|
||||||
|
readable according to policy, but new access must use the current epoch.
|
||||||
|
|
||||||
|
The function-bound container exists independently of membership. It may remain
|
||||||
|
vacant and continue to receive ciphertext without falling back to an unrelated
|
||||||
|
personal mailbox. Zero, one, or several incumbents are valid states. Each
|
||||||
|
incumbent receives an independently auditable, device-bound wrapped-key path;
|
||||||
|
the postbox is never copied into their account ownership.
|
||||||
|
|
||||||
|
A new assignment or hand-over rotates the function/postbox key epoch. Envelope
|
||||||
|
encryption permits the normal rotation path to rewrap per-message data keys
|
||||||
|
rather than rewrite large ciphertext objects; a security policy may require
|
||||||
|
full content re-encryption for selected compromise or cryptographic-profile
|
||||||
|
events. The history available to a new incumbent must be selected policy (all
|
||||||
|
retained history, a bounded historical window, or assignment-time content) and
|
||||||
|
recorded with the grant.
|
||||||
|
|
||||||
|
Delegation is a time-bounded represented-function grant, not a copy or
|
||||||
|
substitution of the postbox. Expiry or withdrawal stops future key release and
|
||||||
|
actions. It cannot revoke plaintext already decrypted, printed, exported, or
|
||||||
|
captured outside the platform. Multiple simultaneous incumbents and delegates
|
||||||
|
remain distinguishable in key-fetch and action evidence.
|
||||||
|
|
||||||
|
Postbox content and signed manifests are immutable. Correction or replacement
|
||||||
|
creates a linked new object/version; it never silently substitutes ciphertext
|
||||||
|
or evidence that another actor may already have inspected.
|
||||||
|
|
||||||
|
## External Recipients
|
||||||
|
|
||||||
|
External recipients may need one-time or time-limited access without a full
|
||||||
|
platform account. The target model should support capability links or invitation
|
||||||
|
tokens that are:
|
||||||
|
|
||||||
|
- scoped to specific message or attachment resources
|
||||||
|
- time-limited
|
||||||
|
- optionally one-time
|
||||||
|
- protected by an out-of-band secret, passphrase, or stronger external identity
|
||||||
|
proof
|
||||||
|
- revocable before key fetch
|
||||||
|
- fully audited
|
||||||
|
|
||||||
|
## Retraction Semantics
|
||||||
|
|
||||||
|
GovOPlaN should be honest about retraction.
|
||||||
|
|
||||||
|
Before a recipient fetches a key or decrypts content, the system can revoke
|
||||||
|
tokens, remove wrapped-key access, expire links, and delete ciphertext according
|
||||||
|
to retention policy.
|
||||||
|
|
||||||
|
After a recipient has decrypted or copied plaintext, the system cannot make the
|
||||||
|
recipient forget it. The platform can only record access, revoke future access,
|
||||||
|
notify parties, and apply legal or organizational controls.
|
||||||
|
|
||||||
|
The UI must explain this distinction whenever it offers expiry, retraction, or
|
||||||
|
message withdrawal.
|
||||||
|
|
||||||
|
## Server Responsibilities
|
||||||
|
|
||||||
|
The server remains important even when content is encrypted:
|
||||||
|
|
||||||
|
- store ciphertext and signed manifests
|
||||||
|
- store routing and policy metadata
|
||||||
|
- enforce access before key release or rewrapping
|
||||||
|
- provide public key directory access
|
||||||
|
- emit notifications without plaintext content
|
||||||
|
- record audit events
|
||||||
|
- enforce retention and expiry where possible
|
||||||
|
- expose diagnostics for delivery and key-access failures
|
||||||
|
|
||||||
|
## Module Ownership
|
||||||
|
|
||||||
|
- `govoplan-postbox` owns postbox bindings, postbox messages, message metadata,
|
||||||
|
and postbox UI.
|
||||||
|
- `govoplan-identity-trust` owns device keys, public key directory, key epochs,
|
||||||
|
and assurance integration.
|
||||||
|
- `govoplan-access` owns current identity, membership, function, delegation, and
|
||||||
|
permission decisions.
|
||||||
|
- `govoplan-policy` owns retention, retraction, and security policy decisions.
|
||||||
|
- `govoplan-audit` owns durable audit traces.
|
||||||
|
- `govoplan-files` owns managed file storage when encrypted postbox attachments
|
||||||
|
are backed by file objects.
|
||||||
|
|
||||||
|
No module should import another module's internals to decrypt content. All
|
||||||
|
interaction must use capabilities, DTOs, and audited service contracts.
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
# Public-Sector Integration Strategy
|
||||||
|
|
||||||
|
GovOPlaN should integrate with the existing public-sector software landscape
|
||||||
|
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:
|
||||||
|
|
||||||
|
- `integrate`: GovOPlaN talks to the system through a stable API/protocol.
|
||||||
|
- `link`: GovOPlaN stores external references and opens the external system for
|
||||||
|
source-of-truth work.
|
||||||
|
- `import`: GovOPlaN consumes data or files into governed module storage.
|
||||||
|
- `synchronize`: GovOPlaN keeps selected records aligned both ways or through a
|
||||||
|
source-of-truth rule.
|
||||||
|
- `replace selected workflow`: GovOPlaN may own a narrow workflow where the
|
||||||
|
external product is weak, but does not replace the whole product family.
|
||||||
|
- `no first-class support`: GovOPlaN only stores manual references unless a
|
||||||
|
deployment project creates a specific connector.
|
||||||
|
|
||||||
|
## Initial Classification
|
||||||
|
|
||||||
|
| System family | Examples | Default strategy | Likely owner |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| File providers | SMB/CIFS, WebDAV, Nextcloud, Seafile, SFTP, S3 | integrate, import, link | `govoplan-files`, connector inventory in `govoplan-connectors` |
|
||||||
|
| Project/task management | OpenProject, Jira, Redmine, Microsoft Planner | link, synchronize selected records, replace selected workflow only after proof | `govoplan-connectors`, later `govoplan-tasks` or workflow modules |
|
||||||
|
| Identity providers | LDAP, Active Directory, OIDC, SAML, OpenDesk IDM | integrate, synchronize | `govoplan-idm`, `govoplan-access` |
|
||||||
|
| Mail and groupware | IMAP/SMTP, Open-Xchange, Exchange/M365, CalDAV/CardDAV | integrate, link | `govoplan-mail`, `govoplan-calendar`, `govoplan-connectors` |
|
||||||
|
| DMS/e-file/archive | d.velop/d.3, enaio, ELO, Fabasoft, CMIS, VIS/eAkte | link, import, synchronize selected metadata | `govoplan-dms`, `govoplan-files`, `govoplan-records` |
|
||||||
|
| ERP/finance/procurement | SAP, MACH, Infoma, DATEV, procurement feeds | export, import, synchronize; do not replace by default | `govoplan-erp`, `govoplan-procurement`, `govoplan-ledger`, `govoplan-payments` |
|
||||||
|
| Public-sector transport | FIT-Connect, XTA/OSCI, Peppol access points | integrate, publish, receive | dedicated protocol modules plus `govoplan-connectors` inventory |
|
||||||
|
| Standards registries | XRepository, XOE/V catalogues | link, import metadata/cache | `govoplan-connectors`, `govoplan-xoev` |
|
||||||
|
| Publication/data exchange | RSS, open-data APIs, API feeds, CSV/Excel drops | consume, publish, transform | proposed `govoplan-datasources`, proposed `govoplan-dataflow`, `govoplan-reporting` |
|
||||||
|
| Collaboration suites | Matrix, Jitsi, BigBlueButton, Nextcloud Talk, Collabora/OnlyOffice | integrate, link; native behavior only for governed evidence | `govoplan-connectors`, `govoplan-dms`, `govoplan-workflow` |
|
||||||
|
| Specialist Fachverfahren | register-specific and domain-specific systems | link first; integrate/import when a real project supplies contracts | domain module or deployment-specific connector |
|
||||||
|
|
||||||
|
## Landscape Catalogue
|
||||||
|
|
||||||
|
This catalogue is intentionally implementation-oriented. Each entry records the
|
||||||
|
first API/auth/data assumptions needed to turn an inventory entry into a
|
||||||
|
connector or module issue.
|
||||||
|
|
||||||
|
### Citizen And Service Portals
|
||||||
|
|
||||||
|
- Strategy: integrate/link first; replace selected intake workflow only when a
|
||||||
|
GovOPlaN portal package owns the complete journey.
|
||||||
|
- Protocol/API surface: REST/JSON APIs, form submission webhooks, OIDC/SAML
|
||||||
|
login, eID interfaces where available, file-upload callbacks, case-status
|
||||||
|
callbacks.
|
||||||
|
- Auth model: OIDC/SAML service clients, signed webhook secrets, tenant-scoped
|
||||||
|
API keys, later eID/trust-provider handoff.
|
||||||
|
- Data shape: applicant identity reference, application form payload,
|
||||||
|
attachment references, consent declarations, status events, receipt IDs.
|
||||||
|
- Deployment assumptions: externally reachable HTTPS, reverse proxy, portal DMZ
|
||||||
|
separation, strict CSRF/origin settings, large upload path.
|
||||||
|
- Risks: personal data exposure, duplicate identity mapping, partial
|
||||||
|
submissions, upload malware, inconsistent portal status models.
|
||||||
|
- MVP test path: submit a test application with one file, create a form
|
||||||
|
submission/case/task stub, return a receipt and status reference.
|
||||||
|
- Owner/priority: `govoplan-portal`, `govoplan-forms-runtime`,
|
||||||
|
`govoplan-files`, Wave 1.
|
||||||
|
|
||||||
|
### DMS, E-File, Records, And Archives
|
||||||
|
|
||||||
|
- Strategy: link/import/synchronize selected metadata; do not replace the DMS by
|
||||||
|
default.
|
||||||
|
- Protocol/API surface: CMIS, WebDAV, vendor REST APIs, S3/object archive
|
||||||
|
staging, file-plan export/import, archive handoff APIs.
|
||||||
|
- Auth model: service accounts, OAuth/OIDC where supported, mTLS for regulated
|
||||||
|
archives, secret references for vendor tokens.
|
||||||
|
- Data shape: document ID, version, file-plan/classification code, retention
|
||||||
|
metadata, owner/case reference, external URL, checksum, lock/legal-hold state.
|
||||||
|
- Deployment assumptions: usually internal network or VPN, strict storage
|
||||||
|
quotas, existing retention policies, archive immutability requirements.
|
||||||
|
- Risks: record duplication, broken legal hold, permission mismatch, version
|
||||||
|
drift, destructive retention/export mistakes.
|
||||||
|
- MVP test path: create a connector inventory entry, test read-only metadata
|
||||||
|
lookup, link one GovOPlaN file/case evidence item to an external document.
|
||||||
|
- Owner/priority: `govoplan-dms`, `govoplan-records`, `govoplan-files`,
|
||||||
|
`govoplan-connectors`, Wave 2/5.
|
||||||
|
|
||||||
|
### ERP, Finance, Procurement, And Accounting
|
||||||
|
|
||||||
|
- Strategy: export/import/synchronize selected records; replacement only by
|
||||||
|
narrow domain decision.
|
||||||
|
- Protocol/API surface: vendor REST/SOAP APIs, CSV/XML batch exchange, SFTP,
|
||||||
|
XRechnung/Peppol, XBestellung/procurement feeds, payment reconciliation files.
|
||||||
|
- Auth model: service accounts, client certificates, mTLS, SFTP keys, token
|
||||||
|
references, environment-specific account separation.
|
||||||
|
- Data shape: debtor/creditor reference, payment request, invoice, order,
|
||||||
|
budget/cost-center code, booking status, receipt/evidence reference.
|
||||||
|
- Deployment assumptions: batch windows, finance-system approval workflows,
|
||||||
|
test tenants often separated from production by vendor process.
|
||||||
|
- Risks: financial posting errors, double export, tax/legal data retention,
|
||||||
|
inconsistent master data, irreversible accounting handoff.
|
||||||
|
- MVP test path: dry-run export of one payment/accounting handoff file with
|
||||||
|
checksum, validation report, and no remote posting.
|
||||||
|
- Owner/priority: `govoplan-payments`, `govoplan-ledger`,
|
||||||
|
`govoplan-xrechnung`, `govoplan-erp`, `govoplan-procurement`, Wave 1/6.
|
||||||
|
|
||||||
|
### Identity, IAM, And Directory Services
|
||||||
|
|
||||||
|
- Strategy: integrate/synchronize; access remains GovOPlaN's local
|
||||||
|
authorization boundary.
|
||||||
|
- Protocol/API surface: LDAP, Active Directory, SCIM, OIDC, SAML, OpenDesk IDM
|
||||||
|
APIs, group membership sync, account deactivation feeds.
|
||||||
|
- Auth model: bind accounts, service clients, OIDC/SAML metadata, SCIM tokens,
|
||||||
|
certificate-backed clients where required.
|
||||||
|
- Data shape: account, user, group, membership, role claim, tenant/org-unit
|
||||||
|
mapping, status, external directory ID.
|
||||||
|
- Deployment assumptions: directory is usually internal; identity provider may
|
||||||
|
be organization-wide and not GovOPlaN-owned.
|
||||||
|
- Risks: privilege escalation through group mapping, stale memberships, account
|
||||||
|
collision, deprovisioning latency, tenant-boundary mistakes.
|
||||||
|
- MVP test path: read-only directory profile test, map one external group to a
|
||||||
|
tenant group, show a dry-run membership diff.
|
||||||
|
- Owner/priority: `govoplan-idm`, `govoplan-access`, Wave 1.
|
||||||
|
|
||||||
|
### Groupware, Mail, Calendar, And Collaboration
|
||||||
|
|
||||||
|
- Strategy: integrate/link; native behavior only where GovOPlaN needs governed
|
||||||
|
evidence or process state.
|
||||||
|
- Protocol/API surface: IMAP/SMTP, CalDAV/CardDAV, Open-Xchange APIs, Microsoft
|
||||||
|
Graph/EWS, Matrix APIs, Jitsi/BigBlueButton APIs, Collabora/OnlyOffice
|
||||||
|
integration points.
|
||||||
|
- Auth model: service accounts, delegated OAuth/OIDC, app passwords, mailbox
|
||||||
|
credentials, groupware-specific tokens, secret references.
|
||||||
|
- Data shape: mailbox folder/message references, event/free-busy data, meeting
|
||||||
|
URL, chat room ID, participant list, document-editing session reference.
|
||||||
|
- Deployment assumptions: often internal/existing tenant infrastructure; mail
|
||||||
|
and calendar may be separate from identity even in OpenDesk-style stacks.
|
||||||
|
- Risks: mail credential exposure, calendar privacy, double invitations, room
|
||||||
|
booking conflicts, chat/document data escaping retention rules.
|
||||||
|
- MVP test path: profile test for mailbox/calendar reachability, read-only
|
||||||
|
folder/free-busy lookup, create a non-production event/message draft.
|
||||||
|
- 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
|
||||||
|
source of settlement truth.
|
||||||
|
- Protocol/API surface: payment provider APIs, redirect/callback flows,
|
||||||
|
reconciliation files, SEPA/export formats, cash-register/cashier interfaces.
|
||||||
|
- Auth model: provider API keys, signed webhooks, client certificates, mTLS,
|
||||||
|
tenant-specific merchant accounts.
|
||||||
|
- Data shape: payment intent, amount/currency, payer reference, provider
|
||||||
|
transaction ID, settlement status, receipt, refund/cancellation reference.
|
||||||
|
- Deployment assumptions: public callback URLs, strict environment separation,
|
||||||
|
PCI-sensitive providers, finance reconciliation cadence.
|
||||||
|
- Risks: duplicate charges, callback replay, amount mismatch, refund workflow
|
||||||
|
gaps, evidence-retention mistakes.
|
||||||
|
- MVP test path: sandbox payment intent, signed callback verification, receipt
|
||||||
|
evidence link, reconciliation dry-run.
|
||||||
|
- Owner/priority: `govoplan-payments`, `govoplan-ledger`, Wave 1/6.
|
||||||
|
|
||||||
|
### Reporting, BI, Open Data, And Publication
|
||||||
|
|
||||||
|
- Strategy: consume/publish/transform; native reporting owns GovOPlaN views, not
|
||||||
|
every external BI product.
|
||||||
|
- Protocol/API surface: SQL read replicas, CSV/Excel export/import, REST APIs,
|
||||||
|
RSS/Atom, open-data APIs, SFTP/WebDAV publication targets.
|
||||||
|
- Auth model: read-only DB users, API tokens, SFTP keys, OAuth clients, public
|
||||||
|
anonymous publication profiles where appropriate.
|
||||||
|
- Data shape: dataset metadata, schema/version, report parameters, generated
|
||||||
|
file references, publication URL, freshness/lineage, validation results.
|
||||||
|
- Deployment assumptions: publication can be public or internal; generated
|
||||||
|
datasets need retention and provenance.
|
||||||
|
- Risks: leaking restricted data, stale publications, schema drift, expensive
|
||||||
|
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`,
|
||||||
|
`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
|
||||||
|
|
||||||
|
- Strategy: integrate/publish/receive; protocol modules own protocol semantics.
|
||||||
|
- Protocol/API surface: FIT-Connect, XTA/OSCI, XRepository, XOE/V, XRechnung,
|
||||||
|
XBestellung, Peppol, registry-specific Fachverfahren APIs.
|
||||||
|
- Auth model: certificates, mTLS, service accounts, destination credentials,
|
||||||
|
protocol-specific trust anchors and key rotation.
|
||||||
|
- Data shape: transport envelope, payload schema/version, destination IDs,
|
||||||
|
receipt/acknowledgement, message status, standard-specific metadata.
|
||||||
|
- Deployment assumptions: regulated trust chains, test/prod endpoint
|
||||||
|
separation, formal onboarding, strict logging and retention expectations.
|
||||||
|
- Risks: invalid schemas, failed delivery receipts, certificate expiry, wrong
|
||||||
|
destination routing, protocol version drift.
|
||||||
|
- MVP test path: validate a sample payload against a schema, test endpoint
|
||||||
|
reachability in sandbox, store receipt/evidence reference.
|
||||||
|
- Owner/priority: `govoplan-fit-connect`, `govoplan-xoev`,
|
||||||
|
`govoplan-xrechnung`, `govoplan-xta-osci`, `govoplan-connectors`, Wave 1/2.
|
||||||
|
|
||||||
|
### File Providers And Shared Storage
|
||||||
|
|
||||||
|
- Strategy: integrate/import/link; files module owns GovOPlaN file semantics.
|
||||||
|
- Protocol/API surface: SMB/CIFS, WebDAV, Nextcloud, Seafile, SFTP, S3,
|
||||||
|
local/object storage profiles.
|
||||||
|
- Auth model: service accounts, user credentials, app tokens, OAuth where
|
||||||
|
supported, secret references, environment variables for deployment-managed
|
||||||
|
credentials.
|
||||||
|
- Data shape: file ID/path, provider object ID, checksum, MIME type, size,
|
||||||
|
version/ETag, owner, permission snapshot, imported file reference.
|
||||||
|
- Deployment assumptions: internal networks, large files, existing shares,
|
||||||
|
variable permissions, provider-specific rate limits.
|
||||||
|
- Risks: permission mismatch, stale imports, overwrites, duplicate files, path
|
||||||
|
traversal, storage growth.
|
||||||
|
- MVP test path: profile test, list folder, import one file into governed
|
||||||
|
storage, keep provider reference and checksum.
|
||||||
|
- Owner/priority: `govoplan-files`, `govoplan-connectors`, Wave 0/1.
|
||||||
|
|
||||||
|
### Project, Task, And Case-Adjacent Systems
|
||||||
|
|
||||||
|
- Strategy: connector-first for OpenProject/Jira/Redmine; native module only
|
||||||
|
when GovOPlaN owns project semantics.
|
||||||
|
- Protocol/API surface: OpenProject API v3, webhooks, Jira/Redmine REST APIs,
|
||||||
|
Microsoft Graph for Planner/Project where applicable.
|
||||||
|
- Auth model: API tokens, OAuth/OIDC apps, webhook secrets, service accounts.
|
||||||
|
- Data shape: project ID, work package/task ID, status, assignee reference,
|
||||||
|
external URL, version/lock token, publish/sync trace.
|
||||||
|
- Deployment assumptions: external project tool remains source of truth for
|
||||||
|
broad project management; GovOPlaN links selected records.
|
||||||
|
- Risks: task duplication, bidirectional sync conflicts, permission mismatch,
|
||||||
|
over-mirroring comments/attachments.
|
||||||
|
- MVP test path: OpenProject profile test, project/work-package lookup,
|
||||||
|
external-reference round-trip.
|
||||||
|
- Owner/priority: `govoplan-connectors`, later `govoplan-tasks`/workflow/cases
|
||||||
|
consumers, Wave 0/2.
|
||||||
|
|
||||||
|
### Specialist Fachverfahren
|
||||||
|
|
||||||
|
- Strategy: link first; integrate/import only when a deployment project supplies
|
||||||
|
concrete contracts and a domain owner.
|
||||||
|
- Protocol/API surface: vendor APIs, CSV/XML batch imports, SFTP, database
|
||||||
|
views, message queues, protocol-specific transports.
|
||||||
|
- Auth model: usually service accounts, VPN, mTLS, SFTP keys, or vendor tokens.
|
||||||
|
- Data shape: domain-specific record IDs, status, applicant/person references,
|
||||||
|
file/evidence references, case/status events.
|
||||||
|
- Deployment assumptions: strongly local/vendor-specific, often no stable test
|
||||||
|
API, data model differs by jurisdiction.
|
||||||
|
- Risks: brittle vendor contracts, legal source-of-truth ambiguity, high
|
||||||
|
customization cost, migration expectations.
|
||||||
|
- MVP test path: inventory entry and manual external-reference link; require a
|
||||||
|
project-specific connector issue before automation.
|
||||||
|
- Owner/priority: domain module or deployment-specific connector, case by case.
|
||||||
|
|
||||||
|
## Prioritization Rules
|
||||||
|
|
||||||
|
1. Start with connectors that unblock Wave 0 or Wave 1 reference journeys.
|
||||||
|
2. Prefer open standards and self-hosted/open-source APIs where they are common
|
||||||
|
in public-sector deployments.
|
||||||
|
3. Treat inventory-only entries as useful because operators need a map of their
|
||||||
|
software landscape even before automation exists.
|
||||||
|
4. Keep connector code in the owning connector/protocol module. Domain modules
|
||||||
|
consume capabilities, DTOs, external references, and events through core.
|
||||||
|
5. Every executable connector needs health diagnostics, secret-reference
|
||||||
|
handling, lifecycle state, audit events, and retirement behavior.
|
||||||
|
|
||||||
|
## Connector Catalogue Handoff
|
||||||
|
|
||||||
|
`govoplan-connectors` owns the detailed catalogue entry shape:
|
||||||
|
|
||||||
|
- connector type key
|
||||||
|
- category and owner module
|
||||||
|
- supported directions and trigger modes
|
||||||
|
- credential and secret handling
|
||||||
|
- health check and diagnostics payload
|
||||||
|
- external-reference shape
|
||||||
|
- required capabilities and optional module combinations
|
||||||
|
- lifecycle support
|
||||||
|
|
||||||
|
Core should only keep strategy, routing, and cross-module architecture notes.
|
||||||
|
Connector implementation and public-sector target inventory belong in
|
||||||
|
`govoplan-connectors`.
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
# Multi Seal Mail - Current RBAC and Resource-Access Model
|
|
||||||
|
|
||||||
**Updated:** 2026-06-16
|
|
||||||
**Current migration head:** `f5a6b7c8d9e0`
|
|
||||||
|
|
||||||
## Authorization Equation
|
|
||||||
|
|
||||||
An operation is permitted only when every applicable layer allows it:
|
|
||||||
|
|
||||||
```text
|
|
||||||
effective role/API-key capability
|
|
||||||
AND resource ownership/share access
|
|
||||||
AND workflow state
|
|
||||||
AND active governance/policy constraints
|
|
||||||
```
|
|
||||||
|
|
||||||
RBAC answers what an actor may do. ACLs answer which resource the actor may do it to. Workflow state and policy decide whether the operation is currently valid.
|
|
||||||
|
|
||||||
## Identity and Scope
|
|
||||||
|
|
||||||
```text
|
|
||||||
Account global login identity
|
|
||||||
+- User membership tenant-local identity
|
|
||||||
+- direct tenant roles
|
|
||||||
+- active group memberships
|
|
||||||
| +- inherited tenant roles
|
|
||||||
+- tenant-local API keys
|
|
||||||
|
|
||||||
Account
|
|
||||||
+- direct system-role assignments
|
|
||||||
```
|
|
||||||
|
|
||||||
A browser session has one active tenant membership. System privileges do not silently grant tenant data access. API keys remain tenant-local and receive the intersection of their configured scopes and their owner's live tenant scopes on every request.
|
|
||||||
|
|
||||||
## Wildcards
|
|
||||||
|
|
||||||
```text
|
|
||||||
tenant:* every canonical tenant permission
|
|
||||||
system:* every canonical system permission
|
|
||||||
* legacy alias interpreted as tenant:* only
|
|
||||||
```
|
|
||||||
|
|
||||||
Tenant wildcards never grant system permissions.
|
|
||||||
|
|
||||||
## Canonical Tenant Permissions - 53
|
|
||||||
|
|
||||||
### Campaigns
|
|
||||||
|
|
||||||
```text
|
|
||||||
campaign:read
|
|
||||||
campaign:create
|
|
||||||
campaign:update
|
|
||||||
campaign:copy
|
|
||||||
campaign:archive
|
|
||||||
campaign:delete
|
|
||||||
campaign:share
|
|
||||||
campaign:validate
|
|
||||||
campaign:build
|
|
||||||
campaign:review
|
|
||||||
campaign:send_test
|
|
||||||
campaign:queue
|
|
||||||
campaign:control
|
|
||||||
campaign:send
|
|
||||||
campaign:retry
|
|
||||||
campaign:reconcile
|
|
||||||
```
|
|
||||||
|
|
||||||
### Recipients
|
|
||||||
|
|
||||||
```text
|
|
||||||
recipients:read
|
|
||||||
recipients:write
|
|
||||||
recipients:import
|
|
||||||
recipients:export
|
|
||||||
```
|
|
||||||
|
|
||||||
### Files
|
|
||||||
|
|
||||||
```text
|
|
||||||
files:read
|
|
||||||
files:download
|
|
||||||
files:upload
|
|
||||||
files:organize
|
|
||||||
files:share
|
|
||||||
files:delete
|
|
||||||
files:admin
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reports and Audit
|
|
||||||
|
|
||||||
```text
|
|
||||||
reports:read
|
|
||||||
reports:export
|
|
||||||
reports:send
|
|
||||||
audit:read
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mail Servers
|
|
||||||
|
|
||||||
```text
|
|
||||||
mail_servers:read
|
|
||||||
mail_servers:use
|
|
||||||
mail_servers:test
|
|
||||||
mail_servers:write
|
|
||||||
mail_servers:manage_credentials
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tenant Administration
|
|
||||||
|
|
||||||
```text
|
|
||||||
admin:users:read
|
|
||||||
admin:users:create
|
|
||||||
admin:users:update
|
|
||||||
admin:users:suspend
|
|
||||||
|
|
||||||
admin:groups:read
|
|
||||||
admin:groups:write
|
|
||||||
admin:groups:manage_members
|
|
||||||
|
|
||||||
admin:roles:read
|
|
||||||
admin:roles:write
|
|
||||||
admin:roles:assign
|
|
||||||
|
|
||||||
admin:api_keys:read
|
|
||||||
admin:api_keys:create
|
|
||||||
admin:api_keys:revoke
|
|
||||||
|
|
||||||
admin:settings:read
|
|
||||||
admin:settings:write
|
|
||||||
admin:policies:read
|
|
||||||
admin:policies:write
|
|
||||||
```
|
|
||||||
|
|
||||||
## Canonical System Permissions - 18
|
|
||||||
|
|
||||||
```text
|
|
||||||
system:tenants:read
|
|
||||||
system:tenants:create
|
|
||||||
system:tenants:update
|
|
||||||
system:tenants:suspend
|
|
||||||
|
|
||||||
system:accounts:read
|
|
||||||
system:accounts:create
|
|
||||||
system:accounts:update
|
|
||||||
system:accounts:suspend
|
|
||||||
|
|
||||||
system:roles:read
|
|
||||||
system:roles:write
|
|
||||||
system:roles:assign
|
|
||||||
|
|
||||||
system:access:read
|
|
||||||
system:access:assign
|
|
||||||
|
|
||||||
system:audit:read
|
|
||||||
system:settings:read
|
|
||||||
system:settings:write
|
|
||||||
system:governance:read
|
|
||||||
system:governance:write
|
|
||||||
```
|
|
||||||
|
|
||||||
`system:access:*` remains as a compatibility/read and assignment boundary for cross-tenant/system access handling. It is not a separate primary UI area.
|
|
||||||
|
|
||||||
## Default Tenant Roles
|
|
||||||
|
|
||||||
- **Owner:** `tenant:*`. At least one active operational owner must remain.
|
|
||||||
- **Tenant administrator:** settings, policies, users, groups, roles and API keys plus read access to campaigns/files/reports/audit. Real delivery remains separately delegable.
|
|
||||||
- **Administrator (legacy):** all tenant permissions for upgraded installations.
|
|
||||||
- **Access administrator:** membership and assignment management within delegation limits.
|
|
||||||
- **Campaign manager:** prepare, validate and build campaigns; no review approval or real delivery by default.
|
|
||||||
- **Reviewer:** inspect and approve prepared campaign messages.
|
|
||||||
- **Sender:** mock-test, queue, control, send, retry and reconcile prepared campaigns; can use/test approved mail profiles.
|
|
||||||
- **File manager:** managed file operations without campaign delivery rights.
|
|
||||||
- **Viewer:** read campaigns, recipients, files and reports.
|
|
||||||
- **Auditor:** read campaigns, recipient evidence, reports and audit records; export detailed evidence.
|
|
||||||
|
|
||||||
## Default System Roles
|
|
||||||
|
|
||||||
- **System owner:** `system:*`, protected. At least one active account must retain it.
|
|
||||||
- **System administrator:** all specific system permissions, editable and not protected.
|
|
||||||
- **System auditor:** read-only system registry/settings/governance/audit role, editable.
|
|
||||||
|
|
||||||
## Delegation Ceiling
|
|
||||||
|
|
||||||
For role definition, assignment and API-key creation:
|
|
||||||
|
|
||||||
```text
|
|
||||||
requested scopes subset of actor delegateable scopes
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
1. Tenant roles may contain tenant scopes only.
|
|
||||||
2. System roles may contain system scopes only.
|
|
||||||
3. Definition rights and assignment rights are separate.
|
|
||||||
4. Group definition and group membership management are separate.
|
|
||||||
5. API-key scopes are intersected with the owner's current effective scopes on every request.
|
|
||||||
6. Suspended accounts, users, tenants or groups stop contributing access immediately.
|
|
||||||
7. Administrative updates are field-sensitive; a user with only status authority cannot change role assignments.
|
|
||||||
|
|
||||||
## Campaign Ownership and ACLs
|
|
||||||
|
|
||||||
A campaign has exactly one owner:
|
|
||||||
|
|
||||||
```text
|
|
||||||
owner user OR owner group
|
|
||||||
```
|
|
||||||
|
|
||||||
Additional active shares may target users or groups with `read` or `write`.
|
|
||||||
|
|
||||||
Resolution:
|
|
||||||
|
|
||||||
- owner user: read and write;
|
|
||||||
- member of owner group: read and write;
|
|
||||||
- explicit read share: read;
|
|
||||||
- explicit write share: read and write;
|
|
||||||
- `tenant:*`: tenant-wide ACL bypass;
|
|
||||||
- ordinary campaign permission without ownership/share: no object access.
|
|
||||||
|
|
||||||
ACLs do not add capabilities. A write share still needs the specific permission for update, validation, review, send, report, retry or reconciliation.
|
|
||||||
|
|
||||||
## Sensitive Recipient Boundary
|
|
||||||
|
|
||||||
Recipient-complete campaign JSON, message data and job detail require `recipients:read`. Recipient edits require `recipients:write`; exports require `recipients:export`; import is reserved for the dedicated recipient import/list workflow.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
| Permission | Operations |
|
|
||||||
|---|---|
|
|
||||||
| `files:read` | list, search, inspect, resolve metadata |
|
|
||||||
| `files:download` | download file bytes and generated ZIP archives |
|
|
||||||
| `files:upload` | upload files and ZIP contents |
|
|
||||||
| `files:organize` | create folders, rename, move, copy and bulk rename |
|
|
||||||
| `files:share` | create/revoke file shares |
|
|
||||||
| `files:delete` | delete/hide files and folders subject to retention |
|
|
||||||
| `files:admin` | tenant-wide administration of user/group file spaces |
|
|
||||||
|
|
||||||
## Mail Servers
|
|
||||||
|
|
||||||
| Permission | Boundary |
|
|
||||||
|---|---|
|
|
||||||
| `mail_servers:read` | profile metadata and effective policy visibility |
|
|
||||||
| `mail_servers:use` | select an approved profile without reading secrets |
|
|
||||||
| `mail_servers:test` | run server-side connection tests |
|
|
||||||
| `mail_servers:write` | define/edit profiles in allowed scopes |
|
|
||||||
| `mail_servers:manage_credentials` | create/replace SMTP/IMAP secrets or campaign-level credentials where policy allows |
|
|
||||||
|
|
||||||
Reusable encrypted profiles now exist. Effective usability is also constrained by hierarchical mail-profile policy, ownership, allowed/forced profile sets, credential inheritance/override rules and allow/deny patterns.
|
|
||||||
|
|
||||||
## Sessions, API Keys and CSRF
|
|
||||||
|
|
||||||
- Browser login creates an HttpOnly session cookie and a separate readable CSRF cookie.
|
|
||||||
- Unsafe cookie-authenticated requests require matching CSRF cookie/header and stored CSRF hash.
|
|
||||||
- API keys remain supported for CLI/automation and do not use browser CSRF.
|
|
||||||
- Login responses still expose a compatibility session token in the response body; the WebUI does not persist it.
|
|
||||||
|
|
||||||
## Legacy Compatibility
|
|
||||||
|
|
||||||
Runtime aliases remain only for names that are no longer canonical, including:
|
|
||||||
|
|
||||||
```text
|
|
||||||
campaign:write
|
|
||||||
attachments:read
|
|
||||||
attachments:write
|
|
||||||
admin:users
|
|
||||||
admin:users:write
|
|
||||||
admin:api_keys:write
|
|
||||||
admin:settings
|
|
||||||
system:tenants:write
|
|
||||||
system:access:write
|
|
||||||
```
|
|
||||||
|
|
||||||
Canonical scopes are not widened by runtime alias expansion after migration.
|
|
||||||
|
|
||||||
## Deferred Permission Families
|
|
||||||
|
|
||||||
Add these only with their corresponding implemented features:
|
|
||||||
|
|
||||||
```text
|
|
||||||
templates:*
|
|
||||||
address_books:*
|
|
||||||
recipient_lists:*
|
|
||||||
connectors:*
|
|
||||||
dsar:*
|
|
||||||
system:monitoring:read
|
|
||||||
system:backups:run
|
|
||||||
system:backups:restore
|
|
||||||
system:updates:apply
|
|
||||||
system:updates:rollback
|
|
||||||
```
|
|
||||||
|
|
||||||
A separate `retention:*` family is not currently canonical because retention is managed through system settings and tenant policy scopes. Add it only if retention operation duties need separation from general policy/settings administration.
|
|
||||||
@@ -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.
|
||||||
+933
-24
@@ -1,50 +1,959 @@
|
|||||||
# GovOPlaN Release Dependencies
|
# GovOPlaN Release Dependencies
|
||||||
|
|
||||||
Release installs must not depend on sibling checkout paths. Local development can keep editable installs and `file:` WebUI links, but release packaging should resolve modules from tagged git refs or from a package registry.
|
This document owns release package composition, signed package catalogs,
|
||||||
|
license checks, catalog publishing, migration baselines, and the final release
|
||||||
|
checklist.
|
||||||
|
|
||||||
## Backend
|
Operator runtime configuration and module install/uninstall execution live in
|
||||||
|
`DEPLOYMENT_OPERATOR_GUIDE.md`.
|
||||||
|
|
||||||
|
## Backend Packages
|
||||||
|
|
||||||
|
Release installs must not depend on sibling checkout paths. Local development
|
||||||
|
can keep editable installs and `file:` WebUI links, but release packaging must
|
||||||
|
resolve modules from tagged git refs or from a package registry.
|
||||||
|
|
||||||
Local development:
|
Local development:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan-core
|
cd /mnt/DATA/git/govoplan
|
||||||
./.venv/bin/python -m pip install -r requirements-dev.txt
|
./.venv/bin/python -m pip install -r requirements-dev.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
Release install from tagged module repositories:
|
Release install from the meta checkout plus tagged module repositories:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan-core
|
cd /mnt/DATA/git/govoplan
|
||||||
./.venv/bin/python -m pip install -r requirements-release.txt
|
./.venv/bin/python -m pip install -r requirements-release.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
`requirements-release.txt` pins the module repositories to the release tag. Update those refs when cutting a release:
|
`../govoplan-core[server]` is resolved relative to the meta requirements file.
|
||||||
|
If you create the virtualenv elsewhere, still run the install command from the
|
||||||
```text
|
meta checkout:
|
||||||
govoplan-files git@git.add-ideas.de:add-ideas/govoplan-files.git v0.1.0
|
|
||||||
govoplan-mail git@git.add-ideas.de:add-ideas/govoplan-mail.git v0.1.0
|
|
||||||
govoplan-campaign git@git.add-ideas.de:add-ideas/govoplan-campaign.git v0.1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
## WebUI
|
|
||||||
|
|
||||||
Local development uses `webui/package.json`, which may point at sibling module checkouts while active development is happening.
|
|
||||||
|
|
||||||
Release WebUI installs should use `webui/package.release.json`. It points module dependencies at the same tagged git repositories. To generate a release lockfile, copy it over `package.json` in a release branch or build workspace and then run `npm install` there:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
/tmp/govoplan-release-test/bin/python -m pip install -r requirements-release.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
`requirements-release.txt` pins the module repositories to the release tag.
|
||||||
|
Update those refs when cutting a release:
|
||||||
|
|
||||||
|
```text
|
||||||
|
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
|
||||||
|
|
||||||
|
Local development uses `webui/package.json`, which may point at sibling module
|
||||||
|
checkouts while active development is happening.
|
||||||
|
|
||||||
|
Release WebUI installs should use `webui/package.release.json`. It points
|
||||||
|
module dependencies at the same tagged git repositories. After the module tags
|
||||||
|
referenced there exist, generate the committed release lockfile without
|
||||||
|
touching the development package files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
tools/release/generate-release-lock.sh
|
||||||
cd /mnt/DATA/git/govoplan-core/webui
|
cd /mnt/DATA/git/govoplan-core/webui
|
||||||
cp package.release.json package.json
|
|
||||||
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm install
|
|
||||||
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run build
|
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
The module repositories include root-level npm package manifests so git installs can resolve `@govoplan/files-webui`, `@govoplan/mail-webui`, and `@govoplan/campaign-webui` from repository roots even though their source lives below `webui/src`.
|
Module repositories with a frontend include root-level npm package manifests
|
||||||
|
so the `@govoplan/*-webui` dependencies in `webui/package.release.json` can be
|
||||||
|
resolved from repository roots even though their source lives below
|
||||||
|
`webui/src`.
|
||||||
|
|
||||||
|
### Release Lockfile Strategy
|
||||||
|
|
||||||
|
The supported backend release composition is the set pinned in the meta
|
||||||
|
repository's `requirements-release.txt`. The supported frontend composition
|
||||||
|
is the independently buildable module set pinned in
|
||||||
|
`webui/package.release.json`; backend-only modules do not need a frontend
|
||||||
|
package entry. Keep one committed full-product release lockfile at
|
||||||
|
`webui/package-lock.release.json`, generated from
|
||||||
|
`webui/package.release.json` in a clean release workspace. Development
|
||||||
|
`package-lock.json` may continue to point at local `file:` dependencies.
|
||||||
|
|
||||||
|
Frontend module permutations are regression-tested through
|
||||||
|
`GOVOPLAN_WEBUI_MODULE_PACKAGES` and temporary build output, not through
|
||||||
|
committed lockfiles for every possible combination. If a smaller composition
|
||||||
|
becomes a separately shipped product, add an explicit release manifest and
|
||||||
|
lockfile pair for that product, for example
|
||||||
|
`package.release.files-mail.json` and `package-lock.release.files-mail.json`,
|
||||||
|
generated in a clean release workspace from tagged git dependencies.
|
||||||
|
|
||||||
|
## Release Tag Script
|
||||||
|
|
||||||
|
The normal release path is automated by `/mnt/DATA/git/govoplan/tools/release/push-release-tag.sh`: it bumps
|
||||||
|
or accepts the target version, updates Python/WebUI/module manifest versions,
|
||||||
|
commits/tags/pushes the module repositories first, regenerates
|
||||||
|
`webui/package-lock.release.json`, and then commits/tags/pushes core. If the
|
||||||
|
working tree has already been bumped, pass the current version explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
tools/release/push-release-tag.sh --version 0.1.8
|
||||||
|
```
|
||||||
|
|
||||||
|
`/mnt/DATA/git/govoplan/tools/release/generate-release-catalog.py` reads installed/discovered
|
||||||
|
`ModuleManifest` objects while writing catalog entries. When a manifest is
|
||||||
|
available, the catalog entry uses the manifest version, points package refs at
|
||||||
|
`v<manifest.version>`, and copies `provides_interfaces` /
|
||||||
|
`requires_interfaces` from the manifest. It also copies module migration order
|
||||||
|
and `migration_tasks` metadata when present. If a manifest cannot be
|
||||||
|
discovered, the entry falls back to the release version passed with `--version`
|
||||||
|
and omits interface and migration-task metadata. This keeps the catalog aligned
|
||||||
|
with independently versioned module packages instead of relying on a hardcoded
|
||||||
|
compatibility table.
|
||||||
|
|
||||||
|
The script also includes GovOPlaN roadmap/scaffold module repositories that do
|
||||||
|
not yet have package metadata. Those repositories are committed, tagged, and
|
||||||
|
pushed with the same release tag, but they are tag-only until they contain
|
||||||
|
`pyproject.toml`, module manifests, or WebUI packages. Tag-only repositories
|
||||||
|
are not listed in `requirements-release.txt` or `webui/package.release.json`.
|
||||||
|
|
||||||
|
Current tag-only module repositories:
|
||||||
|
|
||||||
|
- `govoplan-appointments`
|
||||||
|
- `govoplan-cases`
|
||||||
|
- `govoplan-connectors`
|
||||||
|
- `govoplan-dms`
|
||||||
|
- `govoplan-erp`
|
||||||
|
- `govoplan-fit-connect`
|
||||||
|
- `govoplan-forms`
|
||||||
|
- `govoplan-identity-trust`
|
||||||
|
- `govoplan-ledger`
|
||||||
|
- `govoplan-payments`
|
||||||
|
- `govoplan-portal`
|
||||||
|
- `govoplan-postbox`
|
||||||
|
- `govoplan-reporting`
|
||||||
|
- `govoplan-search`
|
||||||
|
- `govoplan-tasks`
|
||||||
|
- `govoplan-templates`
|
||||||
|
- `govoplan-workflow-engine` (headless definitions, migrations, and execution)
|
||||||
|
- `govoplan-workflow` (optional authoring and inspection WebUI)
|
||||||
|
- `govoplan-xoev`
|
||||||
|
- `govoplan-xrechnung`
|
||||||
|
- `govoplan-xta-osci`
|
||||||
|
|
||||||
|
## Catalog Trust And Licensing
|
||||||
|
|
||||||
|
GovOPlaN module install and uninstall must remain operator-controlled. The
|
||||||
|
running server may plan and validate package changes, but package mutation is
|
||||||
|
performed by the separate installer daemon or an operator shell during
|
||||||
|
maintenance mode.
|
||||||
|
|
||||||
|
`addideas-govoplan-website` is the public static distribution surface for
|
||||||
|
official catalog resources:
|
||||||
|
|
||||||
|
- signed module package catalogs, grouped by release channel
|
||||||
|
- public catalog keyrings
|
||||||
|
- public license verification keyrings
|
||||||
|
- examples and operator-facing download paths
|
||||||
|
|
||||||
|
`govoplan-core` is the verifier and orchestrator:
|
||||||
|
|
||||||
|
- fetches a local or remote module catalog
|
||||||
|
- verifies catalog signatures against configured trusted keys
|
||||||
|
- enforces approved release channels
|
||||||
|
- rejects expired or not-yet-valid catalogs
|
||||||
|
- records accepted catalog sequence numbers for replay protection
|
||||||
|
- checks catalog entry license feature requirements before planning installs
|
||||||
|
- writes installer plans and request records
|
||||||
|
|
||||||
|
Feature and platform modules own their package artifacts, manifests, migration
|
||||||
|
metadata, retirement providers, and optional lifecycle behavior.
|
||||||
|
|
||||||
|
Core accepts either a local catalog file or a remote URL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GOVOPLAN_MODULE_PACKAGE_CATALOG=/srv/govoplan/catalogs/stable.json
|
||||||
|
GOVOPLAN_MODULE_PACKAGE_CATALOG_URL=https://govoplan.example/catalogs/v1/channels/stable.json
|
||||||
|
GOVOPLAN_MODULE_PACKAGE_CATALOG_CACHE=/srv/govoplan/runtime/catalog-cache/stable.json
|
||||||
|
```
|
||||||
|
|
||||||
|
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`
|
||||||
|
- `channel`
|
||||||
|
- `sequence`
|
||||||
|
- `generated_at`
|
||||||
|
- `not_before` when delayed activation is needed
|
||||||
|
- `expires_at`
|
||||||
|
- `modules`
|
||||||
|
- `signatures`
|
||||||
|
|
||||||
|
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
|
||||||
|
target module set
|
||||||
|
- `migration_safety`, one of `automatic`, `requires_review`, `forward_only`,
|
||||||
|
or `destructive`
|
||||||
|
- `migration_notes`, operator-facing data/migration guidance for review,
|
||||||
|
forward-only, or destructive changes
|
||||||
|
- `migration_after` and `migration_before`, explicit module ids used to order
|
||||||
|
module-owned migration heads when a release needs a live-data sequencing rule
|
||||||
|
- `migration_tasks`, constrained live-data tasks that run around Alembic
|
||||||
|
migration phases. Each task declares `task_id`, `phase`, `summary`,
|
||||||
|
`task_version`, `safety`, `idempotent`, and optionally `timeout_seconds`.
|
||||||
|
The allowed phases are `pre_migration_check`, `pre_migration_prepare`,
|
||||||
|
`post_migration_backfill`, and `post_migration_verify`.
|
||||||
|
- `current_version_min` and `current_version_max_exclusive`, the installed
|
||||||
|
version window from which this catalog target may be applied directly
|
||||||
|
- `bridge_release` and `bridge_notes`, marking a target as an intermediate
|
||||||
|
compatibility release in a staged update path
|
||||||
|
- `allow_downgrade` and `allow_same_version`, explicit opt-ins for reviewed
|
||||||
|
rollback or package-refresh plans
|
||||||
|
- `recovery_tested` and `recovery_notes`, documenting the rehearsal for
|
||||||
|
forward-only or destructive data changes
|
||||||
|
- `provides_interfaces`, named interface contracts exported by this module
|
||||||
|
- `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.
|
||||||
|
When `APP_ENV` is `prod` or `production`, module package catalog signature
|
||||||
|
verification is required by default unless
|
||||||
|
`GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE=false` is set explicitly.
|
||||||
|
|
||||||
|
### Independent Module Versions And Interface Ranges
|
||||||
|
|
||||||
|
Modules do not need to ship on the same version number. Release catalogs should
|
||||||
|
pin each package to the exact backend/WebUI ref being installed and declare any
|
||||||
|
cross-module API compatibility through named interfaces.
|
||||||
|
|
||||||
|
Provider shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"provides_interfaces": [
|
||||||
|
{ "name": "files.campaign_attachments", "version": "1.4.0" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Requirement shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"requires_interfaces": [
|
||||||
|
{
|
||||||
|
"name": "files.campaign_attachments",
|
||||||
|
"version_min": "1.0.0",
|
||||||
|
"version_max_exclusive": "2.0.0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
`version_min` is inclusive. `version_max_exclusive` is exclusive, so the range
|
||||||
|
above means `>= 1.0.0` and `< 2.0.0`. Use this for SemVer major-version
|
||||||
|
compatibility lines. Set `"optional": true` only when the module can operate
|
||||||
|
without that interface being present. If a provider is installed but its
|
||||||
|
version is outside the declared optional range, activation is still blocked.
|
||||||
|
|
||||||
|
Catalog validation normalizes these fields and warns when catalog entries do
|
||||||
|
not satisfy each other's ranges. Registry activation and installer preflight
|
||||||
|
perform the blocking checks against the discovered installed manifests before
|
||||||
|
the desired module set is activated.
|
||||||
|
The admin module-management UI shows catalog warnings in the package catalog
|
||||||
|
section and repeats them as warning-level installer preflight issues while a
|
||||||
|
package install is planned.
|
||||||
|
|
||||||
|
Install-plan items carry a `source` field. Manually entered items use
|
||||||
|
`source: "manual"`; entries planned from the package catalog use
|
||||||
|
`source: "catalog"`. Catalog-sourced items also carry a `catalog` metadata
|
||||||
|
object with the validation snapshot used when the item was planned: catalog
|
||||||
|
source/path, source type, cache path, channel, sequence, generated/validity
|
||||||
|
timestamps, signature state, trusted key id, and cache state where available.
|
||||||
|
Catalog provenance changes preflight severity:
|
||||||
|
|
||||||
|
- catalog-sourced installs and updates require a configured, valid package
|
||||||
|
catalog before activation
|
||||||
|
- invalid, untrusted, expired, not-yet-valid, replayed, or unapproved-channel
|
||||||
|
catalogs block catalog-sourced installs and updates
|
||||||
|
- the same catalog validation failures remain warnings for manual install
|
||||||
|
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
|
||||||
|
planned block activation before the installer runs
|
||||||
|
- catalog update targets older than the installed module version block unless
|
||||||
|
the catalog entry declares `allow_downgrade: true`
|
||||||
|
- catalog update targets equal to the installed module version block unless the
|
||||||
|
catalog entry declares `allow_same_version: true`
|
||||||
|
- catalog update targets with a `current_version_min` /
|
||||||
|
`current_version_max_exclusive` window block when the installed version is
|
||||||
|
outside that window; publish and apply a bridge release instead
|
||||||
|
- catalog entries marked `forward_only` or `destructive` block activation until
|
||||||
|
the plan row has an explicit data-safety acknowledgement
|
||||||
|
- catalog entries marked `forward_only` or `destructive` also block unless the
|
||||||
|
catalog entry declares `recovery_tested: true` and either the catalog entry or
|
||||||
|
operator plan row contains recovery notes
|
||||||
|
- catalog entries marked `destructive` also require catalog migration notes or
|
||||||
|
operator notes describing the cleanup or retirement plan
|
||||||
|
|
||||||
|
### Update Paths
|
||||||
|
|
||||||
|
Package updates are target-state operations, not one-module-at-a-time runtime
|
||||||
|
toggles. The safe unit of planning is a desired module version set plus a
|
||||||
|
catalog validation snapshot. The installer may install multiple packages into
|
||||||
|
the environment before activation, then validate the discovered manifests and
|
||||||
|
activate the resulting set together.
|
||||||
|
|
||||||
|
Install-plan rows support explicit `install`, `update`, and `uninstall`
|
||||||
|
actions. Catalog planning writes `update` when the module is already installed.
|
||||||
|
Preflight resolves the target set from installed manifests plus the planned
|
||||||
|
catalog entries. Unplanned catalog entries are not treated as installed. When a
|
||||||
|
catalog entry would satisfy a missing dependency or named interface, preflight
|
||||||
|
blocks activation with a companion-update issue; the admin catalog planner adds
|
||||||
|
those companion rows automatically when it can resolve them from the current
|
||||||
|
catalog. The preflight response also includes a structured `target_plan` summary
|
||||||
|
with each planned module's action, current version, catalog target version,
|
||||||
|
package refs, migration-safety level, current-version update window, bridge
|
||||||
|
metadata, recovery metadata, and acknowledgement state.
|
||||||
|
|
||||||
|
Database migrations are planned against that same target module set. When the
|
||||||
|
installer is run with `--migrate`, it calls `govoplan_core.commands.init_db`
|
||||||
|
with the target enabled modules rather than the pre-update startup module list,
|
||||||
|
so newly installed module migration directories are discovered before
|
||||||
|
activation. Preflight also returns a structured migration plan. Its step order is
|
||||||
|
derived from:
|
||||||
|
|
||||||
|
- manifest and catalog `migration_after` / `migration_before` declarations
|
||||||
|
- module dependencies and optional dependencies when both modules are in the
|
||||||
|
target plan
|
||||||
|
- named interface provider/consumer relationships when both sides are in the
|
||||||
|
target plan
|
||||||
|
|
||||||
|
Preflight blocks cycles in that ordering graph. It also blocks non-idempotent
|
||||||
|
module migration tasks, forward-only/destructive tasks without operator
|
||||||
|
acknowledgement, and installed manifest tasks that declare no executor.
|
||||||
|
Catalog-only task executors are marked as pending because they can only be
|
||||||
|
confirmed after the target package is installed. The migrator runs pre-migration
|
||||||
|
tasks, upgrades the ordered module heads first, finishes with Alembic `heads`,
|
||||||
|
and then runs post-migration tasks, so Alembic's revision graph remains
|
||||||
|
authoritative while GovOPlaN still gives operators a module-aware live-data
|
||||||
|
order.
|
||||||
|
|
||||||
|
This avoids circular "upgrade A first / upgrade B first" traps: named interface
|
||||||
|
requirements are solved against the target set, not against each intermediate
|
||||||
|
package-install moment. If the target set cannot satisfy all non-optional
|
||||||
|
interfaces and module dependencies at once, the plan is invalid. Operators
|
||||||
|
should add the necessary module updates to the same plan instead of trying to
|
||||||
|
force an order.
|
||||||
|
|
||||||
|
Live data upgrades need an even stricter rule:
|
||||||
|
|
||||||
|
- migrations must be idempotent and ordered by module migration metadata
|
||||||
|
- destructive schema/data changes need an explicit retirement or cleanup plan,
|
||||||
|
not an automatic package update side effect
|
||||||
|
- cross-module data migrations must be compatible with both the old and target
|
||||||
|
provider interface until activation finishes
|
||||||
|
- rollback must restore the package set and database state together, or be
|
||||||
|
documented as forward-only with a tested recovery procedure
|
||||||
|
- if two modules require mutually incompatible live-data states, the catalog
|
||||||
|
must publish an intermediate compatibility release rather than a circular
|
||||||
|
update chain
|
||||||
|
|
||||||
|
The release catalog is the first safety gate for this. Generated catalogs mark
|
||||||
|
modules with registered migrations as `requires_review` by default. Release
|
||||||
|
authors should keep that value for ordinary reversible migrations, raise it to
|
||||||
|
`forward_only` when database rollback requires restoring a snapshot, and raise
|
||||||
|
it to `destructive` when the update removes or irreversibly rewrites persisted
|
||||||
|
data. Forward-only and destructive entries must include `recovery_tested: true`
|
||||||
|
and recovery notes after a verified restore or forward-recovery rehearsal. The
|
||||||
|
admin install-plan UI exposes the safety level and lets operators record an
|
||||||
|
explicit acknowledgement; preflight keeps acknowledged forward-only/destructive
|
||||||
|
changes visible as warnings.
|
||||||
|
|
||||||
|
In practice, circular dependencies are avoided by designing interfaces with
|
||||||
|
compatibility windows and by publishing bridge releases. A bridge release keeps
|
||||||
|
the old interface while introducing the new one, allowing dependent modules to
|
||||||
|
move first; a later release can retire the old interface after every dependent
|
||||||
|
module has a compatible target version. Use `current_version_min` and
|
||||||
|
`current_version_max_exclusive` to make those direct-update windows explicit in
|
||||||
|
the catalog, and set `bridge_release: true` on intermediate targets that exist
|
||||||
|
primarily to carry installations safely across a compatibility gap.
|
||||||
|
|
||||||
|
Trusted catalog keys are configured locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE=/srv/govoplan/trust/catalog-keyring.json
|
||||||
|
GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS='{"release-key-1":"<base64 public key>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
For development or tightly controlled deployments, a keyring can be read from a
|
||||||
|
URL and cached:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_URL=https://govoplan.example/catalogs/v1/keyring.json
|
||||||
|
GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_CACHE=/srv/govoplan/runtime/catalog-cache/keyring.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Production installations should pin the trusted keyring locally or ship it
|
||||||
|
through deployment configuration. Fetching trusted keys from the same public
|
||||||
|
origin as the catalog is convenient, but that origin must not become the only
|
||||||
|
trust root.
|
||||||
|
|
||||||
|
## Dependency Audits
|
||||||
|
|
||||||
|
Dependency vulnerability checks are documented in
|
||||||
|
[`DEPENDENCY_AUDITS.md`](DEPENDENCY_AUDITS.md). The local audit runner is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
bash tools/checks/check-dependency-audits.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The Gitea workflow in `govoplan/.gitea/workflows/dependency-audit.yml` runs the same
|
||||||
|
check against release dependency refs on pushes, pull requests, and a weekly
|
||||||
|
schedule.
|
||||||
|
|
||||||
|
Keyring entries support:
|
||||||
|
|
||||||
|
- `key_id`
|
||||||
|
- `public_key` or `public_key_base64`
|
||||||
|
- `status`: `active`, `next`, `retired`, `revoked`, or `disabled`
|
||||||
|
- `not_before`
|
||||||
|
- `not_after`
|
||||||
|
|
||||||
|
Rotation process:
|
||||||
|
|
||||||
|
1. Add the next public key to the local trusted keyring with status `next`.
|
||||||
|
2. Publish catalogs signed by both current and next keys.
|
||||||
|
3. Upgrade installations so the next key is locally trusted.
|
||||||
|
4. Promote the next key to `active`.
|
||||||
|
5. Retire the old key only after every supported installation trusts the new
|
||||||
|
key.
|
||||||
|
6. Mark a compromised key `revoked` and publish a higher sequence catalog
|
||||||
|
signed by an uncompromised key.
|
||||||
|
|
||||||
|
Use replay state in production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GOVOPLAN_MODULE_PACKAGE_CATALOG_SEQUENCE_STATE=/srv/govoplan/runtime/catalog-sequences.json
|
||||||
|
GOVOPLAN_MODULE_PACKAGE_CATALOG_ENFORCE_SEQUENCE=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Core records the accepted sequence per channel after a catalog entry is planned
|
||||||
|
from the admin interface. With strict sequence enforcement, a previously
|
||||||
|
accepted sequence is rejected; without strict enforcement, only older sequences
|
||||||
|
are rejected. Catalogs should always expire.
|
||||||
|
|
||||||
|
The sequence state file is operational state, not a trust root. Keep it on
|
||||||
|
persistent storage and include it in normal backups:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"stable": {
|
||||||
|
"last_sequence": 42,
|
||||||
|
"accepted_at": "2026-07-07T12:00:00Z",
|
||||||
|
"key_id": "release-key-1",
|
||||||
|
"source": "https://govoplan.example/catalogs/v1/channels/stable.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the file is lost, restore it from backup. If no backup exists, reconstruct
|
||||||
|
each channel from the highest sequence already accepted in installer run
|
||||||
|
records, release records, or the currently deployed module package set. Do not
|
||||||
|
lower `last_sequence` to make an older catalog pass; publish a new higher
|
||||||
|
sequence catalog when the accepted point is uncertain.
|
||||||
|
|
||||||
|
If the file is corrupted, copy it aside for incident review, validate the
|
||||||
|
current signed catalog with channel and freshness enforcement, then rewrite the
|
||||||
|
state with the known accepted sequence. Keep
|
||||||
|
`GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE=true` and approved-channel
|
||||||
|
checks enabled during recovery. Temporarily disabling
|
||||||
|
`GOVOPLAN_MODULE_PACKAGE_CATALOG_ENFORCE_SEQUENCE` allows revalidating the same
|
||||||
|
sequence, but older sequences remain rejected once the reconstructed
|
||||||
|
`last_sequence` is in place.
|
||||||
|
|
||||||
|
Approved channels are deployment policy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS=stable,lts
|
||||||
|
GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE=true
|
||||||
|
```
|
||||||
|
|
||||||
|
The admin UI can display other catalog metadata, but core rejects catalogs from
|
||||||
|
unapproved channels when validation is configured.
|
||||||
|
|
||||||
|
Catalog entries can require license features:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"license_features": ["module.mail", "support.standard"]
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
GOVOPLAN_LICENSE_TRUSTED_KEYS_FILE=/srv/govoplan/trust/license-keyring.json
|
||||||
|
```
|
||||||
|
|
||||||
|
License files are JSON objects with:
|
||||||
|
|
||||||
|
- `license_id`
|
||||||
|
- `subject`
|
||||||
|
- `features`
|
||||||
|
- `valid_from`
|
||||||
|
- `valid_until`
|
||||||
|
- `signature`
|
||||||
|
|
||||||
|
Issue or renew a license from an operator/release shell that has the Ed25519
|
||||||
|
private key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
govoplan-module-installer \
|
||||||
|
--issue-license /srv/govoplan/license.json \
|
||||||
|
--license-id customer-2026-07 \
|
||||||
|
--license-subject "Example Municipality" \
|
||||||
|
--license-feature module.mail \
|
||||||
|
--license-feature support.standard \
|
||||||
|
--license-valid-until 2027-07-31T23:59:59Z \
|
||||||
|
--license-signing-key-id license-issuer-1 \
|
||||||
|
--license-signing-private-key /srv/govoplan/secrets/license-issuer-1.pem \
|
||||||
|
--format json
|
||||||
|
```
|
||||||
|
|
||||||
|
Validate an imported license without exposing secrets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
govoplan-module-installer \
|
||||||
|
--validate-license /srv/govoplan/license.json \
|
||||||
|
--license-trusted-key license-issuer-1="<base64 public key>" \
|
||||||
|
--require-trusted-license \
|
||||||
|
--license-required-feature module.mail \
|
||||||
|
--format json
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI and admin module catalog panel report the license id, subject,
|
||||||
|
validity window, signing key id, signed/trusted state, available features, and
|
||||||
|
missing entitlements for the configured package catalog. They do not expose
|
||||||
|
private signing material.
|
||||||
|
|
||||||
|
License enforcement can run in observe-only mode by leaving
|
||||||
|
`GOVOPLAN_LICENSE_ENFORCEMENT` unset. In that mode, missing or invalid license
|
||||||
|
data is surfaced as a warning but does not block planning.
|
||||||
|
|
||||||
|
Renewal is an ordinary re-issuance with a new `license_id`, extended
|
||||||
|
`valid_until`, and the full intended feature set. Import the renewed JSON to
|
||||||
|
`GOVOPLAN_LICENSE_FILE`, keep the previous file for audit, and validate it
|
||||||
|
before setting enforcement.
|
||||||
|
|
||||||
|
Revocation is handled through the trusted license keyring. Mark a compromised
|
||||||
|
or invalid issuer key as `revoked` or `disabled`, publish or deploy the updated
|
||||||
|
keyring, then reissue affected licenses with an active key. Installations that
|
||||||
|
run with `GOVOPLAN_LICENSE_ENFORCEMENT=true` reject licenses signed only by a
|
||||||
|
revoked key after the local keyring is updated.
|
||||||
|
|
||||||
|
Emergency fallback is deliberately explicit. Operators can temporarily unset
|
||||||
|
`GOVOPLAN_LICENSE_ENFORCEMENT` to keep package planning observable while a
|
||||||
|
license or keyring is recovered. Record the change in the operational incident
|
||||||
|
log, keep catalog signature and channel enforcement enabled, and restore
|
||||||
|
license enforcement after a trusted renewal validates successfully.
|
||||||
|
|
||||||
|
Licensing is intentionally separate from open-source code licensing. The
|
||||||
|
catalog/license mechanism can govern support channels, official release
|
||||||
|
eligibility, hosted update access, professional support, or commercial
|
||||||
|
entitlements without changing the source license of the repositories.
|
||||||
|
|
||||||
|
Production-grade distribution still needs remote registry/git artifact
|
||||||
|
resolution before package-manager apply, a hardened catalog publishing pipeline
|
||||||
|
that writes to `addideas-govoplan-website`, and automated key rotation and
|
||||||
|
emergency revocation drills.
|
||||||
|
|
||||||
|
## Release Catalog Publishing
|
||||||
|
|
||||||
|
GovOPlaN release catalogs are published to `addideas-govoplan-website` as
|
||||||
|
static JSON and verified by `govoplan-core` before installer plans are
|
||||||
|
accepted. Private signing keys must stay outside all git repositories. Public
|
||||||
|
keyrings are published with the website.
|
||||||
|
|
||||||
|
Create the first catalog signing key on the release machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
KEY_DIR="$HOME/.config/govoplan/release-keys"
|
||||||
|
mkdir -p "$KEY_DIR"
|
||||||
|
./.venv/bin/python tools/release/generate-catalog-keypair.py \
|
||||||
|
--key-id release-key-1 \
|
||||||
|
--private-key "$KEY_DIR/release-key-1.pem" \
|
||||||
|
--public-key "$KEY_DIR/release-key-1.pub" \
|
||||||
|
--keyring "$KEY_DIR/catalog-keyring.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `release-key-1.pem` private. The generated keyring contains only public
|
||||||
|
material.
|
||||||
|
|
||||||
|
Generate the signed catalog into `addideas-govoplan-website`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
KEY_DIR="$HOME/.config/govoplan/release-keys"
|
||||||
|
tools/release/publish-release-catalog.sh \
|
||||||
|
--version <x.y.z> \
|
||||||
|
--sequence 202607071340 \
|
||||||
|
--catalog-signing-key "release-key-1=$KEY_DIR/release-key-1.pem" \
|
||||||
|
--build-web
|
||||||
|
```
|
||||||
|
|
||||||
|
This writes:
|
||||||
|
|
||||||
|
- `/mnt/DATA/git/addideas-govoplan-website/public/catalogs/v1/channels/stable.json`
|
||||||
|
- `/mnt/DATA/git/addideas-govoplan-website/public/catalogs/v1/keyring.json`
|
||||||
|
|
||||||
|
The wrapper validates the catalog with core using the generated public keyring.
|
||||||
|
|
||||||
|
For normal module/core releases, first audit and record migration baselines,
|
||||||
|
then tag and push the module/core repos. Finally publish the website catalog:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
./.venv/bin/python tools/release/release-migration-audit.py --strict
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
KEY_DIR="$HOME/.config/govoplan/release-keys"
|
||||||
|
tools/release/publish-release-catalog.sh \
|
||||||
|
--version <x.y.z> \
|
||||||
|
--catalog-signing-key "release-key-1=$KEY_DIR/release-key-1.pem" \
|
||||||
|
--build-web \
|
||||||
|
--commit \
|
||||||
|
--tag \
|
||||||
|
--push
|
||||||
|
```
|
||||||
|
|
||||||
|
The website tag is `catalog-v<x.y.z>`. The public URL is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The public keyring URL is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://govoplan.add-ideas.de/catalogs/v1/keyring.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`/mnt/DATA/git/govoplan/tools/release/push-release-tag.sh` can publish the web catalog after module and core
|
||||||
|
tags have been pushed. It runs the migration release audit in automatic mode:
|
||||||
|
warning-only before the first recorded migration baseline, strict after a
|
||||||
|
baseline exists. Add `--strict-migration-audit` when you want to force strict
|
||||||
|
mode explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
KEY_DIR="$HOME/.config/govoplan/release-keys"
|
||||||
|
tools/release/push-release-tag.sh \
|
||||||
|
--bump subversion \
|
||||||
|
--strict-migration-audit \
|
||||||
|
--publish-web-catalog \
|
||||||
|
--catalog-signing-key "release-key-1=$KEY_DIR/release-key-1.pem" \
|
||||||
|
--build-web-catalog
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--catalog-signing-key` more than once during a key rotation window. The
|
||||||
|
catalog will contain multiple signatures and the public keyring will include the
|
||||||
|
corresponding public keys.
|
||||||
|
|
||||||
|
## Release Doctor
|
||||||
|
|
||||||
|
Use `/mnt/DATA/git/govoplan/tools/release/release-doctor.py` before release preparation and again before
|
||||||
|
publishing. It inspects repository state, local versions, release tags,
|
||||||
|
migration audit state, release package refs, stable catalog/keyring state, and
|
||||||
|
optionally public catalog availability. The default mode is read-only and
|
||||||
|
offline. Status output is intentionally concise:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
./.venv/bin/python tools/release/release-doctor.py status --target-version <x.y.z>
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--details` when the full findings should be printed directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./.venv/bin/python tools/release/release-doctor.py status --target-version <x.y.z> --details
|
||||||
|
```
|
||||||
|
|
||||||
|
For concise guidance, print only suggested next commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./.venv/bin/python tools/release/release-doctor.py next --target-version <x.y.z>
|
||||||
|
```
|
||||||
|
|
||||||
|
For an operator-guided session, run interactive mode. The doctor asks which
|
||||||
|
suggested command to run; mutating commands require typing `RUN` before they
|
||||||
|
execute:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./.venv/bin/python tools/release/release-doctor.py interactive --target-version <x.y.z>
|
||||||
|
```
|
||||||
|
|
||||||
|
Interactive mode starts with a compact summary and waits for an action. It can
|
||||||
|
open the full report in the configured pager, run one suggested command, repair
|
||||||
|
Git `safe.directory` dubious-ownership blocks after explicit confirmation, push
|
||||||
|
all clean repositories that are ahead of their upstream, or commit and push all
|
||||||
|
dirty repositories. The dirty-repository bulk action asks for a commit message,
|
||||||
|
skips repositories that are behind upstream, and requires typing
|
||||||
|
`COMMIT AND PUSH` before it stages, commits, and pushes.
|
||||||
|
|
||||||
|
Add `--online` when remote tag and public catalog/keyring reachability should be
|
||||||
|
checked. Add `--json` when a CI job or another tool should consume the report.
|
||||||
|
|
||||||
|
On a GovOPlaN installation that should consume the official stable catalog:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GOVOPLAN_MODULE_PACKAGE_CATALOG_URL=https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json
|
||||||
|
GOVOPLAN_MODULE_PACKAGE_CATALOG_CACHE=/srv/govoplan/runtime/catalog-cache/stable.json
|
||||||
|
GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE=true
|
||||||
|
GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS=stable
|
||||||
|
GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE=/srv/govoplan/trust/catalog-keyring.json
|
||||||
|
GOVOPLAN_MODULE_PACKAGE_CATALOG_SEQUENCE_STATE=/srv/govoplan/runtime/catalog-sequences.json
|
||||||
|
GOVOPLAN_MODULE_PACKAGE_CATALOG_ENFORCE_SEQUENCE=true
|
||||||
|
```
|
||||||
|
|
||||||
|
For production, copy the public keyring into deployment configuration and pin it
|
||||||
|
locally. Do not rely on a URL-fetched keyring as the only trust root.
|
||||||
|
|
||||||
|
`stable.json` includes a top-level `core_release` section for operator/update
|
||||||
|
tooling. Core is intentionally not listed as a normal module entry because it
|
||||||
|
must not be added to saved enabled-module state. Core upgrades should remain an
|
||||||
|
operator-supervised package update with restart and health checks.
|
||||||
|
|
||||||
|
Key rotation for published catalogs:
|
||||||
|
|
||||||
|
1. Generate the next private key outside git.
|
||||||
|
2. Run `/mnt/DATA/git/govoplan/tools/release/publish-release-catalog.sh` with both signing keys.
|
||||||
|
3. Publish the web catalog/keyring.
|
||||||
|
4. Roll the new public keyring into installations.
|
||||||
|
5. Stop signing with the old key after the supported fleet trusts the new key.
|
||||||
|
6. Mark compromised keys as revoked in the public keyring and publish a higher
|
||||||
|
sequence catalog signed by a trusted uncompromised key.
|
||||||
|
|
||||||
|
## PostgreSQL Release Check
|
||||||
|
|
||||||
|
Release candidates should pass a disposable PostgreSQL migration and startup
|
||||||
|
smoke check before tagging or publishing catalogs. Start the local testbed,
|
||||||
|
then run the permutation check from the core checkout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan/dev/postgres
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose --env-file .env up -d
|
||||||
|
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
set -a
|
||||||
|
. /mnt/DATA/git/govoplan/dev/postgres/.env
|
||||||
|
set +a
|
||||||
|
tools/checks/postgres-integration-check.py \
|
||||||
|
--database-url "$GOVOPLAN_POSTGRES_DATABASE_URL" \
|
||||||
|
--reset-schema
|
||||||
|
```
|
||||||
|
|
||||||
|
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. 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
|
||||||
|
release, unreleased development migrations may be squashed into a release-level
|
||||||
|
baseline or release-to-release upgrade migration. After a release tag has
|
||||||
|
shipped, released migration revision IDs are immutable.
|
||||||
|
|
||||||
|
The release policy is:
|
||||||
|
|
||||||
|
- unreleased development migrations live in `dev_versions` and are not deleted
|
||||||
|
when a release shortcut is added;
|
||||||
|
- released migrations live in `versions` and are never rewritten or deleted;
|
||||||
|
- future release shortcuts are additive. A new release-to-release migration
|
||||||
|
starts from the previous recorded release heads, so installations can upgrade
|
||||||
|
through sane release steps instead of replaying every development revision;
|
||||||
|
- each stable release records the public migration head revisions in
|
||||||
|
`docs/migration-release-baselines.json`;
|
||||||
|
- `heads` records the Alembic dependency-leaf graph heads for a full graph,
|
||||||
|
while `owner_heads` records the latest migration revision per migration
|
||||||
|
owner for subset installs and review;
|
||||||
|
- fresh installations should apply release-level baselines/upgrades, not
|
||||||
|
unreleased create-then-rename churn;
|
||||||
|
- release-to-release schema changes should be folded into one reviewed
|
||||||
|
migration per migration owner where practical.
|
||||||
|
|
||||||
|
The default runtime track is `release`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
GOVOPLAN_MIGRATION_TRACK=release ./.venv/bin/python -m govoplan_core.commands.init_db
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `dev` only for local/disposable development databases that intentionally
|
||||||
|
need the detailed chain:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
GOVOPLAN_MIGRATION_TRACK=dev ./.venv/bin/python -m govoplan_core.commands.init_db
|
||||||
|
```
|
||||||
|
|
||||||
|
Production, release checks, operator install flows, and the normal development
|
||||||
|
launcher should stay on the `release` track unless a fresh/disposable database
|
||||||
|
is intentionally being used to test the detailed development chain.
|
||||||
|
|
||||||
|
Audit the current graph during release preparation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
./.venv/bin/python tools/release/release-migration-audit.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Audit the detailed development graph separately when a squash is prepared:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./.venv/bin/python tools/release/release-migration-audit.py --track dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate the reviewed/manual squash checklist:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./.venv/bin/python tools/release/release-migration-audit.py --squash-plan
|
||||||
|
```
|
||||||
|
|
||||||
|
After the release migrations have been reviewed and the graph is final, record
|
||||||
|
the release baseline:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./.venv/bin/python tools/release/release-migration-audit.py --record-release <x.y.z>
|
||||||
|
```
|
||||||
|
|
||||||
|
Use strict mode to verify that the current heads are recorded:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./.venv/bin/python tools/release/release-migration-audit.py --strict
|
||||||
|
```
|
||||||
|
|
||||||
|
`/mnt/DATA/git/govoplan/tools/release/push-release-tag.sh` runs the audit by default in automatic mode:
|
||||||
|
non-strict while no release baseline exists, strict after the first baseline is
|
||||||
|
recorded. Pass `--warn-migration-audit` for an explicit non-strict audit,
|
||||||
|
`--strict-migration-audit` to force strict mode, or `--skip-migration-audit`
|
||||||
|
only for emergency/manual release work.
|
||||||
|
|
||||||
|
The first public baseline is v0.1.7. It intentionally adds release-track
|
||||||
|
shortcuts for the unreleased v0.0.0 -> v0.1.7 development chains while keeping
|
||||||
|
the detailed chains on the `dev` track. No production installations existed
|
||||||
|
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
|
||||||
|
`GovOPlaN/govoplan-core#223`.
|
||||||
|
|
||||||
|
## Related Operator Documents
|
||||||
|
|
||||||
|
- `DEPLOYMENT_OPERATOR_GUIDE.md`: runtime environment, explicit migrations,
|
||||||
|
backup/restore commands, module installer daemon/supervisor operation, and
|
||||||
|
rollback drills.
|
||||||
|
- `REMOTE_WEBUI_BUNDLES.md`: experimental browser-loaded module bundles for
|
||||||
|
controlled deployments; normal releases use package builds.
|
||||||
|
|
||||||
## Release Checklist
|
## Release Checklist
|
||||||
|
|
||||||
- Keep Python package versions, WebUI package versions, and git tags aligned.
|
- Keep Python package versions, WebUI package versions, and git tags aligned.
|
||||||
- Tag core, files, mail, and campaign repositories together.
|
- Tag core, access, admin, tenancy, policy, audit, files, mail, campaign,
|
||||||
- Update `requirements-release.txt` and `webui/package.release.json` when the release tag changes.
|
calendar, and scaffold module repositories together.
|
||||||
- Generate release lockfiles from release manifests in a clean build workspace.
|
- Update meta `requirements-release.txt` and core `webui/package.release.json` when the
|
||||||
|
release tag changes.
|
||||||
|
- Generate the committed full-product release lockfile from
|
||||||
|
`package.release.json` with `/mnt/DATA/git/govoplan/tools/release/generate-release-lock.sh`.
|
||||||
|
- Run `/mnt/DATA/git/govoplan/tools/release/release-migration-audit.py --strict` after recording a release
|
||||||
|
baseline.
|
||||||
|
- Run the PostgreSQL release check against a disposable database.
|
||||||
|
- Publish the signed catalog through the release catalog publishing flow above.
|
||||||
|
- Add separate release manifest/lockfile pairs only for module compositions
|
||||||
|
that are shipped as their own products.
|
||||||
- Do not commit local sibling paths into release manifests.
|
- Do not commit local sibling paths into release manifests.
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# Remote WebUI Bundle Loading
|
||||||
|
|
||||||
|
GovOPlaN WebUI modules normally ship through the core WebUI package graph:
|
||||||
|
install a tagged npm/git dependency, run `npm install`, rebuild the shell, and
|
||||||
|
restart or reload the served assets. Remote WebUI bundles are an experimental
|
||||||
|
future path for controlled deployments where a backend-enabled module is not in
|
||||||
|
the local WebUI package graph and the operator wants the shell to load its
|
||||||
|
frontend without rebuilding core.
|
||||||
|
|
||||||
|
This design defines the target guardrails. The current rebuild/reload path
|
||||||
|
remains the default production path.
|
||||||
|
|
||||||
|
## Current Rebuild Path
|
||||||
|
|
||||||
|
The supported release path is:
|
||||||
|
|
||||||
|
1. Install or remove backend and WebUI package dependencies through the trusted
|
||||||
|
installer CLI/daemon.
|
||||||
|
2. Snapshot package state before mutation.
|
||||||
|
3. Run `npm install` and optionally `npm run build`.
|
||||||
|
4. Restart or reload the served WebUI assets.
|
||||||
|
5. Use installer rollback if package apply, restart, or health checks fail.
|
||||||
|
|
||||||
|
Strengths:
|
||||||
|
|
||||||
|
- package manager and lockfile semantics stay conventional
|
||||||
|
- CSP can stay strict because all code is served as built assets
|
||||||
|
- rollback restores package files and lockfiles
|
||||||
|
- local development uses sibling workspace dependencies naturally
|
||||||
|
|
||||||
|
Costs:
|
||||||
|
|
||||||
|
- frontend changes require a rebuild/reload
|
||||||
|
- hot enabling a module with frontend code is not possible in the running shell
|
||||||
|
- failed rebuilds happen at install time, not at lazy module-load time
|
||||||
|
|
||||||
|
## Remote Bundle Path
|
||||||
|
|
||||||
|
Remote loading is only for modules that are enabled by the backend and absent
|
||||||
|
from `virtual:govoplan-installed-modules`. The backend manifest exposes:
|
||||||
|
|
||||||
|
- `FrontendModule.asset_manifest`
|
||||||
|
- `asset_manifest_integrity`
|
||||||
|
- `asset_manifest_signature`
|
||||||
|
- `asset_manifest_public_key_id`
|
||||||
|
- `asset_manifest_contract_version`
|
||||||
|
|
||||||
|
The WebUI shell fetches the asset manifest, verifies manifest integrity and/or
|
||||||
|
signature, validates the manifest contract, fetches the entry bundle, verifies
|
||||||
|
the entry integrity, imports the bundle, validates the exported
|
||||||
|
`PlatformWebModule`, and applies backend metadata before registering routes,
|
||||||
|
navigation, and UI capabilities.
|
||||||
|
|
||||||
|
Unsigned and unhashed manifests are skipped. Entries without integrity are
|
||||||
|
skipped. A module id mismatch is skipped.
|
||||||
|
|
||||||
|
## Asset Manifest Contract
|
||||||
|
|
||||||
|
Contract version `1`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"contractVersion": "1",
|
||||||
|
"moduleId": "files",
|
||||||
|
"entry": "./files-webui.remote.js",
|
||||||
|
"entryIntegrity": "SHA-256-<base64-or-hex-digest>",
|
||||||
|
"moduleExport": "default"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend manifest carries the manifest-level trust metadata. The remote
|
||||||
|
asset manifest carries the concrete entry URL and entry digest.
|
||||||
|
|
||||||
|
## Compatibility Checks
|
||||||
|
|
||||||
|
Before a remote bundle is accepted:
|
||||||
|
|
||||||
|
- backend module must be enabled
|
||||||
|
- local WebUI module with the same id must be absent
|
||||||
|
- manifest contract must be supported by the shell
|
||||||
|
- manifest `moduleId`, exported module `id`, and backend module id must match
|
||||||
|
- backend metadata remains authoritative for label, version, dependencies,
|
||||||
|
nav, and runtime UI capability exposure
|
||||||
|
- future contract versions must declare required shell capabilities so old
|
||||||
|
shells fail closed
|
||||||
|
|
||||||
|
Remote bundles must not broaden backend permissions. They can only contribute
|
||||||
|
routes/nav/capabilities that the backend metadata and existing permission
|
||||||
|
checks allow.
|
||||||
|
|
||||||
|
## CSP
|
||||||
|
|
||||||
|
The current implementation imports verified entry bytes through a blob URL. A
|
||||||
|
production CSP for this mode must explicitly allow:
|
||||||
|
|
||||||
|
- `connect-src` for the approved asset-manifest and bundle origins
|
||||||
|
- `script-src` for `blob:` only when remote loading is enabled
|
||||||
|
- no `unsafe-inline` requirement for remote modules
|
||||||
|
|
||||||
|
If an installation cannot allow `blob:` scripts, the follow-up implementation
|
||||||
|
should use signed same-origin module assets with static URLs instead of blob
|
||||||
|
imports. Remote loading must be disableable by configuration so strict-CSP
|
||||||
|
deployments can keep the rebuild path only.
|
||||||
|
|
||||||
|
## Cache Invalidation
|
||||||
|
|
||||||
|
The shell cache key is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<module-id>:<asset-manifest-url>:<backend-module-version>
|
||||||
|
```
|
||||||
|
|
||||||
|
Release catalogs should publish immutable manifest and entry URLs or change at
|
||||||
|
least one cache-key component on every release. Emergency rollback can point the
|
||||||
|
backend manifest at a previous immutable manifest URL or lower the enabled
|
||||||
|
module version after the backend package rollback.
|
||||||
|
|
||||||
|
Browsers may still cache remote responses. Approved asset servers should use:
|
||||||
|
|
||||||
|
- long cache lifetimes only for content-addressed immutable assets
|
||||||
|
- short cache lifetimes or explicit revalidation for channel/latest manifest
|
||||||
|
aliases
|
||||||
|
- `Cache-Control: no-store` for emergency override manifests
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Remote WebUI rollback is metadata rollback, not package-manager rollback:
|
||||||
|
|
||||||
|
- if the backend package install rolls back, the previous backend frontend
|
||||||
|
metadata returns
|
||||||
|
- if only remote assets are bad, publish a new manifest URL or revert the
|
||||||
|
backend/frontend metadata to the last known good manifest
|
||||||
|
- failed remote loading must degrade by omitting the remote module frontend,
|
||||||
|
not by breaking the shell
|
||||||
|
|
||||||
|
Installer run records should eventually include remote asset manifest URLs,
|
||||||
|
integrity, signature key ids, and load-test results when a plan enables a
|
||||||
|
remote frontend.
|
||||||
|
|
||||||
|
## Local And Development Behavior
|
||||||
|
|
||||||
|
Local development should keep using workspace/file dependencies and Vite. Remote
|
||||||
|
loading is useful for integration testing release artifacts, not for day-to-day
|
||||||
|
module UI development.
|
||||||
|
|
||||||
|
Development deployments may use unsigned catalogs and local asset servers only
|
||||||
|
when signature/integrity enforcement is intentionally disabled. The remote
|
||||||
|
loader itself still requires an integrity hash or a verifiable signature.
|
||||||
|
|
||||||
|
## Follow-Up Slices
|
||||||
|
|
||||||
|
1. Add a server-side remote-bundle policy flag and surface whether remote
|
||||||
|
loading is enabled in platform metadata.
|
||||||
|
2. Add CSP documentation/config generation for strict rebuild-only mode versus
|
||||||
|
controlled remote-bundle mode.
|
||||||
|
3. Add an installer/catalog preflight that validates remote WebUI asset
|
||||||
|
manifests and records verified identity in installer run records.
|
||||||
|
4. Add Playwright coverage for a signed test remote bundle, failed digest, bad
|
||||||
|
module id, cache-key refresh, and fallback when the module is unavailable.
|
||||||
|
5. Add release tooling to emit immutable remote asset manifests with digest,
|
||||||
|
signature, key id, and rollback metadata.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Security Audit Toolchain
|
||||||
|
|
||||||
|
The shared GovOPlaN security audit toolbox moved to the meta repository.
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
tools/checks/security-audit/run.sh --mode ci --scope govoplan
|
||||||
|
tools/checks/security-audit/run.sh --mode full --scope govoplan
|
||||||
|
```
|
||||||
|
|
||||||
|
Canonical documentation:
|
||||||
|
|
||||||
|
- `/mnt/DATA/git/govoplan/docs/operations/SECURITY_AUDIT.md`
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Self-Hosted Installability
|
||||||
|
|
||||||
|
GovOPlaN uses a staged self-hosted installability path.
|
||||||
|
|
||||||
|
## Packaging Decision
|
||||||
|
|
||||||
|
The early packaging approach is a staged combination:
|
||||||
|
|
||||||
|
1. Generate an explicit environment template and validate it before startup.
|
||||||
|
2. Use a Compose-backed production-like development profile for local rehearsal.
|
||||||
|
3. Use the deployment operator guide as the runbook for migrations, workers,
|
||||||
|
backups, health checks, and module installer rollback drills.
|
||||||
|
4. Use the module installer CLI/daemon for package mutation once the runtime is
|
||||||
|
already installed and under maintenance mode.
|
||||||
|
|
||||||
|
This keeps first installation understandable while still preserving the later
|
||||||
|
goal of install/update/uninstall through signed catalogs and the installer
|
||||||
|
daemon. The API server must not run package managers from request handlers.
|
||||||
|
|
||||||
|
## Config Bootstrap
|
||||||
|
|
||||||
|
Generate a self-hosted template:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
./.venv/bin/python -m govoplan_core.commands.config env-template \
|
||||||
|
--profile self-hosted \
|
||||||
|
--generate-secrets \
|
||||||
|
--output .env.self-hosted
|
||||||
|
```
|
||||||
|
|
||||||
|
Validate the current shell environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -a
|
||||||
|
. .env.self-hosted
|
||||||
|
set +a
|
||||||
|
./.venv/bin/python -m govoplan_core.commands.config validate --profile self-hosted
|
||||||
|
```
|
||||||
|
|
||||||
|
The command reports all known blockers at once. Production-like/self-hosted
|
||||||
|
profiles require explicit `APP_ENV`, `DATABASE_URL`, `MASTER_KEY_B64`,
|
||||||
|
`ENABLED_MODULES`, `CORS_ORIGINS`, `GOVOPLAN_TRUSTED_HOSTS`, and a deployment-wide decision for
|
||||||
|
`GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS`. Production rejects SQLite, development
|
||||||
|
bootstrap, insecure auth cookies, and unsigned catalog trust roots when a
|
||||||
|
catalog source is configured.
|
||||||
|
|
||||||
|
Connector process-secret names and custom CA files are deployment-owned through
|
||||||
|
the exact, default-empty `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST` and
|
||||||
|
`GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST`; tenant/API configuration cannot widen
|
||||||
|
either boundary.
|
||||||
|
|
||||||
|
## Production-Like Dev Stack
|
||||||
|
|
||||||
|
Use the local production-like wrapper for repeatable rehearsal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
tools/launch/production-like-dev.sh validate-config
|
||||||
|
tools/launch/production-like-dev.sh seed
|
||||||
|
tools/launch/production-like-dev.sh start
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop Docker dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/launch/production-like-dev.sh stop
|
||||||
|
```
|
||||||
|
|
||||||
|
Reset all profile data:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/launch/production-like-dev.sh reset --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
The start command delegates to `tools/launch/launch-production-like-dev.sh`, which
|
||||||
|
runs API, worker, and WebUI in the foreground. Stop those processes with
|
||||||
|
`Ctrl+C` in the launcher terminal.
|
||||||
|
|
||||||
|
## Module Boundary Gate
|
||||||
|
|
||||||
|
`govoplan/tools/checks/check_dependency_boundaries.py` is part of the focused verification
|
||||||
|
path. It checks backend imports and WebUI package/source imports so modules do
|
||||||
|
not grow hidden runtime dependencies on each other. Feature modules should
|
||||||
|
integrate through core capabilities, backend APIs/events, route contributions,
|
||||||
|
or explicit UI extension points.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
# Multi Seal Mail - Current System and Tenant Governance Model
|
|
||||||
|
|
||||||
**Updated:** 2026-06-16
|
|
||||||
**Current migration head:** `f5a6b7c8d9e0`
|
|
||||||
|
|
||||||
## Governance Rule
|
|
||||||
|
|
||||||
System policy is authoritative for tenants and all lower levels. Each lower level may only narrow what it inherits:
|
|
||||||
|
|
||||||
```text
|
|
||||||
system
|
|
||||||
-> tenant
|
|
||||||
-> user or group owner
|
|
||||||
-> campaign
|
|
||||||
```
|
|
||||||
|
|
||||||
Lower levels do not widen privileges, allowed profiles, retention durations or credential rights granted by a higher level.
|
|
||||||
|
|
||||||
## Administration Structure
|
|
||||||
|
|
||||||
```text
|
|
||||||
SYSTEM
|
|
||||||
- Settings
|
|
||||||
- Retention
|
|
||||||
- Mail servers
|
|
||||||
- Tenants
|
|
||||||
- Users
|
|
||||||
- Groups
|
|
||||||
- System roles
|
|
||||||
- Tenant roles
|
|
||||||
- Audit
|
|
||||||
|
|
||||||
TENANT
|
|
||||||
- Settings boundary
|
|
||||||
- Users
|
|
||||||
- Groups
|
|
||||||
- Roles
|
|
||||||
- API keys
|
|
||||||
- Mail servers
|
|
||||||
- Retention
|
|
||||||
- Audit
|
|
||||||
|
|
||||||
USER
|
|
||||||
- User mail
|
|
||||||
- User retention
|
|
||||||
|
|
||||||
GROUP
|
|
||||||
- Group mail
|
|
||||||
- Group retention
|
|
||||||
```
|
|
||||||
|
|
||||||
There is no separate System access page. Compatibility access scopes remain in the backend for assignment/read boundaries.
|
|
||||||
|
|
||||||
## Tenant Governance
|
|
||||||
|
|
||||||
System settings define tenant defaults and whether tenants may narrow selected options. Tenant overrides can only restrict:
|
|
||||||
|
|
||||||
- custom groups;
|
|
||||||
- custom roles;
|
|
||||||
- tenant API keys.
|
|
||||||
|
|
||||||
The backend enforces that tenant governance cannot widen system-denied privileges.
|
|
||||||
|
|
||||||
## Mail-Profile Governance
|
|
||||||
|
|
||||||
Mail server profiles may exist at these scopes:
|
|
||||||
|
|
||||||
```text
|
|
||||||
system
|
|
||||||
tenant
|
|
||||||
user
|
|
||||||
group
|
|
||||||
campaign
|
|
||||||
```
|
|
||||||
|
|
||||||
Effective campaign profile availability follows campaign ownership. A campaign owned by a user resolves through system, tenant, that user and campaign policy. A group-owned campaign resolves through system, tenant, that group and campaign policy.
|
|
||||||
|
|
||||||
Policy semantics:
|
|
||||||
|
|
||||||
- higher levels define the maximum available profile set;
|
|
||||||
- lower levels can further restrict the set;
|
|
||||||
- forced profiles mean the lower level must choose from the forced set;
|
|
||||||
- a forced set with one profile effectively enforces that profile;
|
|
||||||
- campaign-level profile creation is allowed only if the effective policy permits it;
|
|
||||||
- credentials may be inherited from the authoritative profile or supplied by a lower level only when override is allowed;
|
|
||||||
- deny patterns always win over allow patterns;
|
|
||||||
- empty or `*` allowlist means allow all except denied;
|
|
||||||
- non-empty allowlist means at least one allow rule must match and no deny rule may match.
|
|
||||||
|
|
||||||
Pattern targets:
|
|
||||||
|
|
||||||
```text
|
|
||||||
SMTP hostname
|
|
||||||
IMAP hostname
|
|
||||||
envelope sender
|
|
||||||
From header
|
|
||||||
recipient domains
|
|
||||||
```
|
|
||||||
|
|
||||||
Ownership transfer is intentionally deferred as a two-step workflow: original owner initiates, new owner accepts and reselects/repairs the mail profile if their effective policy requires it.
|
|
||||||
|
|
||||||
## Retention Governance
|
|
||||||
|
|
||||||
Retention policy is hierarchical:
|
|
||||||
|
|
||||||
```text
|
|
||||||
system -> tenant -> user/group -> campaign
|
|
||||||
```
|
|
||||||
|
|
||||||
Managed fields:
|
|
||||||
|
|
||||||
- raw campaign JSON retention days;
|
|
||||||
- generated EML retention days;
|
|
||||||
- stored report detail retention days;
|
|
||||||
- mock mailbox retention days;
|
|
||||||
- audit detail retention days.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- system may set concrete defaults or unlimited retention;
|
|
||||||
- system exposes allow-limiting toggles per field;
|
|
||||||
- tenants, users/groups and campaigns may only shorten inherited retention where the parent allows limiting;
|
|
||||||
- blank lower-level values inherit;
|
|
||||||
- mock mailbox retention is currently system-level because mock mailbox records do not yet carry tenant/campaign ownership metadata;
|
|
||||||
- dry-run/apply retention actions report affected classes before destructive cleanup.
|
|
||||||
|
|
||||||
## Role Definitions and Assignments
|
|
||||||
|
|
||||||
### System roles
|
|
||||||
|
|
||||||
System roles define instance-wide permissions. `system:*` is stored as one wildcard and displayed as granting the full system catalogue. System owner is protected.
|
|
||||||
|
|
||||||
### Tenant roles
|
|
||||||
|
|
||||||
Tenant roles can be system-governed templates or tenant-local definitions, subject to system tenant-governance settings and actor delegation ceilings. Wildcard counts are expanded against the canonical tenant catalogue.
|
|
||||||
|
|
||||||
## Audit Access
|
|
||||||
|
|
||||||
Audit access remains scope-separated:
|
|
||||||
|
|
||||||
```text
|
|
||||||
system audit -> system:audit:read
|
|
||||||
tenant audit -> active tenant + audit:read
|
|
||||||
```
|
|
||||||
|
|
||||||
Audit pages use server pagination, filtering and bounded grids.
|
|
||||||
|
|
||||||
## Tenant Switching
|
|
||||||
|
|
||||||
Tenant switching preserves the current URL when possible and falls back when a route/resource is not accessible in the new tenant context.
|
|
||||||
|
|
||||||
The tenant selector is hidden for ordinary single-tenant accounts and visible for multi-tenant or system tenant-management contexts.
|
|
||||||
|
|
||||||
## DataGrid Contract in Administration
|
|
||||||
|
|
||||||
Admin lists use bounded container grids:
|
|
||||||
|
|
||||||
- one flexible fill column;
|
|
||||||
- fixed total table width;
|
|
||||||
- compact action/status/count columns;
|
|
||||||
- resizable text/date columns;
|
|
||||||
- no intrinsic content growth;
|
|
||||||
- sticky headers where needed;
|
|
||||||
- server pagination for audit.
|
|
||||||
|
|
||||||
## Still Deferred
|
|
||||||
|
|
||||||
- real SMTP/IMAP test-bed verification and operator runbook;
|
|
||||||
- recipient import with column mapping;
|
|
||||||
- Seafile/external connector governance;
|
|
||||||
- system/tenant/group/user file-space hierarchy and external storage hierarchy;
|
|
||||||
- session/device revocation UI;
|
|
||||||
- backup/restore, monitoring and update procedures;
|
|
||||||
- DSAR workflows and evidence bundle verifier;
|
|
||||||
- campaign ownership transfer workflow;
|
|
||||||
- policy impact analysis before delete/disable/unshare/change;
|
|
||||||
- LDAP/OIDC/SAML provisioning;
|
|
||||||
- destructive tenant erasure orchestration.
|
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Shared fixed-window throttling
|
||||||
|
|
||||||
|
Core exposes `govoplan_core.core.throttling` for security-sensitive endpoints
|
||||||
|
that need bounded fixed-window counters. Subjects are SHA-256 hashed before they
|
||||||
|
become store keys. A configured Redis instance provides atomic counters shared
|
||||||
|
across API workers. Development and temporary Redis outages use a bounded
|
||||||
|
process-local fallback. Production-like startup rejects an enabled login
|
||||||
|
throttle without `REDIS_URL` unless
|
||||||
|
`GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE=true` explicitly acknowledges the
|
||||||
|
single-process limitation. When Redis fails at runtime, local attempts are still
|
||||||
|
mirrored so losing the distributed store does not reset the active worker's
|
||||||
|
protection window.
|
||||||
|
|
||||||
|
Callers define one or more `ThrottleDimension` values with a controlled
|
||||||
|
namespace, a subject and a positive limit. They must call `check` before an
|
||||||
|
expensive verifier, `record` after a failed attempt, and may `reset` the relevant
|
||||||
|
dimension after successful verification. A blocked decision includes a
|
||||||
|
`retry_after_seconds` value suitable for an HTTP `Retry-After` header.
|
||||||
|
|
||||||
|
The first consumer is Scheduling's anonymous participation password challenge.
|
||||||
|
Its namespace is `poll-participation-password`; its subject combines tenant,
|
||||||
|
scheduling request and Poll's non-secret invitation-token fingerprint. Access's
|
||||||
|
login throttle predates this primitive and should be migrated onto it in a
|
||||||
|
separate compatibility-preserving slice.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
# GovOPlaN UI/UX Decision Ledger
|
||||||
|
|
||||||
|
This ledger records product UI/UX decisions that affect admin, settings,
|
||||||
|
configuration, connector, policy, and module-management surfaces. It is a
|
||||||
|
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: `GovOPlaN/govoplan-core#225`.
|
||||||
|
|
||||||
|
## Operating Rule
|
||||||
|
|
||||||
|
GovOPlaN must expose advanced platform capability without presenting the user
|
||||||
|
with every option at once. Non-technical users should be able to complete common
|
||||||
|
workflows through guided, plain-language flows. Expert and diagnostic detail may
|
||||||
|
exist, but it must be deliberately layered.
|
||||||
|
|
||||||
|
The ethical design doctrine in `INTERFACE_ETHICS_AND_DESIGN_DOCTRINE.md` is the
|
||||||
|
normative baseline for this ledger. A screen can be visually quiet and still be
|
||||||
|
ethically complete only when it preserves context, consequence,
|
||||||
|
contestability, responsibility, and traceability at the point of action.
|
||||||
|
|
||||||
|
## Binding Decisions
|
||||||
|
|
||||||
|
| ID | Decision | Status | Applies To |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| UX-001 | Use progressive disclosure by default. Common decisions stay visible; advanced or hazardous options live in collapsed panels, later wizard steps, or explicit advanced sections. | Accepted | Admin, settings, connector setup, policy editors, module operations |
|
||||||
|
| UX-002 | Raw JSON is not a primary configuration editor. Every normal configuration path needs typed controls, validation, and help text. JSON may be shown for import/export, diagnostics, or expert inspection only. | Accepted | All admin/configuration UIs |
|
||||||
|
| UX-003 | Prefer guided workflows over option dumps for setup and risky changes. Use wizards for connector setup, configuration package import, module install/uninstall, destructive actions, and policy changes with broad impact. | Accepted | Connectors, package import, module lifecycle, governance/policy |
|
||||||
|
| UX-004 | Discover technical values when possible. Ask for the smallest user-known input, then discover and prefill technical fields for review. | Accepted | File connectors, mail/groupware, public URLs, future external providers |
|
||||||
|
| UX-005 | Disabled actions and failed steps must explain why they are unavailable, who can fix them, and where to go next. Silent disabled states are not acceptable for primary actions. | Accepted | All primary actions |
|
||||||
|
| UX-006 | Explanations must be available but quiet. Use short inline text and expose richer explanations through help affordances, side panels, expandable sections, or review steps. | Accepted | All complex forms and flows |
|
||||||
|
| UX-007 | Creation/editing should prefer modals or focused step flows when it reduces page clutter. Overview and comparison screens remain full-page. | Accepted | Settings/admin surfaces |
|
||||||
|
| UX-008 | Similar concepts must use shared placement and components: server/credential/policy rows, problem lists, review steps, advanced panels, confirmation modals, and empty/error states. | Accepted | Core WebUI and module WebUIs |
|
||||||
|
| UX-009 | Preflight and diagnostics are product UX. Validation, policy, permission, dependency, and capability failures must be written for operators before exposing internal details. | Accepted | Installer, connectors, policy, package import |
|
||||||
|
| UX-010 | Context, decision, and consequence must be visible together for actions that affect rights, duties, records, money, communication, retention, external systems, or workflow state. | Accepted | Workflow, admin, portal, policy, connector, records, payments |
|
||||||
|
| UX-011 | Navigation is not consent. Route changes, panel switches, and passive selection must not execute consequential actions without an explicit action surface. | Accepted | All WebUI surfaces |
|
||||||
|
| UX-012 | Automated actions must remain inspectable. The UI must show the system actor, trigger, policy result, observed effects, and failure/manual-intervention state when automation changes administrative state. | Accepted | Workflow, automation, connectors, tasks, audit |
|
||||||
|
| UX-013 | Contestable decisions must expose provenance. Denials, locks, generated outputs, calculated defaults, policy decisions, access decisions, and retention decisions need a reachable source path. | Accepted | Policy, access, templates, workflow, retention, records |
|
||||||
|
| UX-014 | Retraction, expiry, undo, rollback, and delete controls must state the real limit of the operation. Corrective or future-only actions must not be described as if they undo already observed effects. | Accepted | Postbox, files, records, installer, workflow, payments |
|
||||||
|
| UX-015 | Core owns the platform appearance contract. Modules must use shared CSS tokens and shared controls for theme-aware UI; they must not define independent light/dark palette systems. | Accepted | Core shell and all module WebUIs |
|
||||||
|
| UX-016 | Full-page create/edit surfaces keep `Discard` and the named `Save …` action in the upper-right page action cluster, with Save at the far right. Their position remains stable through validation and loading states. | Accepted | All full-page create/edit surfaces |
|
||||||
|
| UX-017 | Table row actions use icon-only controls in a stable rightmost column and intent order: inspect/open, edit, copy, transfer/share/download, retry/restore, destructive action last. Every icon requires a translated accessible name and tooltip. | Accepted | All structured tables |
|
||||||
|
| UX-018 | A collapsible card containing only one table gives the table the card's full available body, without decorative inner wrappers, duplicate padding, max-widths, or nested scrolling. | Accepted | List, detail, workflow, and configuration surfaces |
|
||||||
|
| UX-019 | Focused-view precedence is manual session pin, current-task suggestion, user default, role/tenant default, then the full interface. The active source and a full-interface escape remain visible; a suggested view never changes authorization or implies consent. | Accepted | Shell, modules, future workflow composition |
|
||||||
|
| UX-020 | Centrally exported Core components are mandatory wherever their contract covers the interaction. A custom reusable control, presentation primitive, or module-local substitute requires explicit product-owner authorization, a narrowly specific purpose, and documented rationale and scope; it must not duplicate a central component. | Accepted | Core WebUI and all module WebUIs |
|
||||||
|
| UX-021 | A collection-wide create action belongs in that collection's page heading and is not duplicated in a persistent side panel. When a side panel is the creation surface, it is present for the creation view only. | Accepted | List-detail, directory, and create surfaces |
|
||||||
|
| UX-022 | Use central `Card` components for logical sections, `DataGrid` for tabular row collections and their ordered actions, and `ToggleSwitch` for boolean settings. Repeatable people/contact editors use one structured row per person with name, email address, and actions; free-form address parsing is reserved for an explicitly designed bulk-import flow. | Accepted | All WebUI forms and collection editors |
|
||||||
|
| UX-023 | `FieldLabel` is the standard label/help surface for every field that is not self-explanatory. Any field rendered without it must be recorded in the omission register below, including its accessible-name source and rationale. Users may hide inline help markers through their persisted interface preference; the field label itself remains visible. | Accepted | All Core and module forms |
|
||||||
|
| UX-024 | Explicit `Discard` actions and dirty in-application navigation use the shared `UnsavedChangesProvider` dialog. A page registers save/discard behavior with `useUnsavedDraftGuard`; its Discard button calls `requestDiscard`, and route changes use `useGuardedNavigate` or `requestNavigation`. | Accepted | All create/edit surfaces |
|
||||||
|
| UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code |
|
||||||
|
| UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables |
|
||||||
|
| 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
|
||||||
|
|
||||||
|
These decisions were accepted on 2026-07-09 for the first implementation slice.
|
||||||
|
They shape reusable components and screen structure. Future changes must revise
|
||||||
|
this section and update affected screens.
|
||||||
|
|
||||||
|
### DUE-001: Primary Admin Configuration Shell
|
||||||
|
|
||||||
|
Decision: admin/configuration surfaces should standardize on a two-zone layout.
|
||||||
|
|
||||||
|
- Left or top area: searchable overview/list, status, and primary actions.
|
||||||
|
- Main area: selected item summary and common settings.
|
||||||
|
- Modal/wizard: create, connect, edit, test, review, and confirm actions.
|
||||||
|
- Collapsed advanced panels: rarely used technical fields.
|
||||||
|
|
||||||
|
Use this for file connectors and mail servers first, then migrate policy,
|
||||||
|
retention, API keys, and module operations.
|
||||||
|
|
||||||
|
### DUE-002: Wizard Step Model
|
||||||
|
|
||||||
|
Decision: standard wizard steps and names use this baseline:
|
||||||
|
|
||||||
|
1. Choose type or scope.
|
||||||
|
2. Enter essentials.
|
||||||
|
3. Discover or test.
|
||||||
|
4. Configure ownership and policy.
|
||||||
|
5. Review changes and blockers.
|
||||||
|
6. Save or submit for operator action.
|
||||||
|
|
||||||
|
Not every wizard needs every step, but flows should use these names and order
|
||||||
|
where applicable.
|
||||||
|
|
||||||
|
This applies to explicit assisted setup, onboarding, import, preflight, and
|
||||||
|
risky multi-step operations. It is not the default shape for ordinary create or
|
||||||
|
edit dialogs.
|
||||||
|
|
||||||
|
### DUE-003: Explanation Placement
|
||||||
|
|
||||||
|
Decision: richer explanations should use this placement model:
|
||||||
|
|
||||||
|
- Short helper text below labels only when it prevents common mistakes.
|
||||||
|
- Tooltips for icon-only controls and compact terms.
|
||||||
|
- Expandable "Why?" or "Details" blocks for contextual explanations.
|
||||||
|
- Right-side detail panel or review step for preflight/provenance/diagnostics.
|
||||||
|
|
||||||
|
Avoid permanently visible paragraphs inside dense admin cards.
|
||||||
|
|
||||||
|
### DUE-004: Advanced Options Contract
|
||||||
|
|
||||||
|
Decision: advanced options are fields that are needed for control, compatibility,
|
||||||
|
or diagnostics but are not part of the common setup path.
|
||||||
|
|
||||||
|
- protocol-specific endpoints, ports, path overrides, TLS/signing toggles,
|
||||||
|
timeout/retry tuning, raw headers, migration/destructive flags, and fallback
|
||||||
|
compatibility settings are advanced.
|
||||||
|
- names, descriptions, provider type, base URL, ownership, policy mode, and
|
||||||
|
basic credentials are not advanced.
|
||||||
|
|
||||||
|
Advanced fields must still be editable through typed controls.
|
||||||
|
|
||||||
|
### DUE-005: Blocker Language Contract
|
||||||
|
|
||||||
|
Decision: all disabled or blocked primary actions should use a shared structured
|
||||||
|
reason object.
|
||||||
|
|
||||||
|
Shape:
|
||||||
|
|
||||||
|
- `summary`: short plain-language reason.
|
||||||
|
- `details`: optional explanation.
|
||||||
|
- `required_action`: what needs to happen.
|
||||||
|
- `actor`: who can do it, for example system administrator or tenant admin.
|
||||||
|
- `target`: where to go or which setting/capability is missing.
|
||||||
|
- `technical_details`: optional expandable developer/operator data.
|
||||||
|
|
||||||
|
For simple missing required fields, highlight the fields and put the structured
|
||||||
|
reason in a compact hover/focus bubble on the disabled primary action instead of
|
||||||
|
adding a large persistent warning block.
|
||||||
|
|
||||||
|
### DUE-006: First Migration Surface
|
||||||
|
|
||||||
|
Decision: convert file connectors first, then mail servers. They share the same
|
||||||
|
server/credential/policy model, are high-value, and will prove the reusable
|
||||||
|
patterns quickly.
|
||||||
|
|
||||||
|
### DUE-007: Adaptive Create/Edit Forms
|
||||||
|
|
||||||
|
Decision: ordinary create/edit dialogs should show the full editable state in an
|
||||||
|
adaptive form, not force a linear wizard.
|
||||||
|
|
||||||
|
- The user chooses a type, provider, credential mode, or policy mode.
|
||||||
|
- The dialog immediately adjusts to show only the fields relevant to that state.
|
||||||
|
- Editing an existing object uses the same field grouping and layout as creating
|
||||||
|
it, so users can recognize the state they configured.
|
||||||
|
- Discovery and test actions must give visible feedback in the same dialog:
|
||||||
|
directly usable, discovered alternative, credentials needed/rejected, or not
|
||||||
|
found with the next action the user can take.
|
||||||
|
- Wizard shells remain available for assisted setup, first-run guidance,
|
||||||
|
imports, discovery-heavy flows, and operational preflight workflows.
|
||||||
|
|
||||||
|
### DUE-008: Platform Theme Contract
|
||||||
|
|
||||||
|
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. 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.
|
||||||
|
|
||||||
|
### DUE-009: Central Component And Exception Contract
|
||||||
|
|
||||||
|
Decision: module interfaces are compositions of the components exported by
|
||||||
|
`@govoplan/core-webui`. When Core already owns the matching interaction, using
|
||||||
|
the central component is required rather than preferred.
|
||||||
|
|
||||||
|
A route or domain-specific page composed from central components is ordinary
|
||||||
|
module composition. A new reusable UI control, presentation primitive, or
|
||||||
|
module-local substitute is a custom component. Before one is implemented, the
|
||||||
|
product owner must explicitly authorize it and the owning decision or issue must
|
||||||
|
record:
|
||||||
|
|
||||||
|
- its single, narrowly defined purpose and intended consumers
|
||||||
|
- why central components or their composition cannot meet that purpose
|
||||||
|
- the permitted scope and the boundary it must not grow beyond
|
||||||
|
- accessibility, reachable states, theme behavior, and test expectations
|
||||||
|
- whether the component remains domain-owned or is a candidate for Core
|
||||||
|
|
||||||
|
Custom components must not duplicate, fork, or cosmetically replace a central
|
||||||
|
component. An existing local implementation does not grant an exception. If a
|
||||||
|
central contract later covers the need, migrate to it unless the product owner
|
||||||
|
explicitly retains the exception.
|
||||||
|
|
||||||
|
### DUE-010: Scheduling Request Reference Composition
|
||||||
|
|
||||||
|
Decision: Scheduling requests provide a concrete reference application of the
|
||||||
|
universal placement and component rules.
|
||||||
|
|
||||||
|
- The persistent left panel stacks `My scheduling requests` and `Scheduling
|
||||||
|
requests for me`; it is list context, not a second creation affordance.
|
||||||
|
- The left panel's `Scheduling requests` header owns one `Add` action. It opens
|
||||||
|
the shared view/create/edit surface in the right main panel.
|
||||||
|
- Basic information, Calendar integration, candidate slots, and participants
|
||||||
|
use the central `Card` component as four logical sections.
|
||||||
|
- Candidate slots and participants use the central `DataGrid`, including its
|
||||||
|
standard row-action placement and order.
|
||||||
|
- Calendar integration uses the central `ToggleSwitch`, with its dependent
|
||||||
|
controls shown when enabled.
|
||||||
|
- Each participant is edited as one structured row with name, email address,
|
||||||
|
and actions. The normal editor does not parse a free-form list of addresses;
|
||||||
|
that interaction requires a separate, explicitly designed bulk-import flow.
|
||||||
|
|
||||||
|
Equivalent list/create/edit surfaces use the same underlying rules. These are
|
||||||
|
not Scheduling-local component variants.
|
||||||
|
|
||||||
|
### DUE-011: Field Help, Discard, And Table Action Contracts
|
||||||
|
|
||||||
|
Decision: the central components own these interactions; modules compose them
|
||||||
|
instead of reproducing their behavior.
|
||||||
|
|
||||||
|
- `FormField` and `ToggleSwitch` already render `FieldLabel`. Direct field
|
||||||
|
compositions use `FieldLabel` explicitly when the meaning or limitation is
|
||||||
|
not self-explanatory.
|
||||||
|
- `help` content is contextual guidance, not the accessible name. The persisted
|
||||||
|
`show_inline_help_hints` user preference hides only the `InlineHelp` marker by
|
||||||
|
applying `ui-hide-help-hints` at the document root. When shown, the shared
|
||||||
|
marker is a labelled, keyboard-focusable help control and exposes its tooltip
|
||||||
|
on focus as well as pointer hover.
|
||||||
|
- 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
|
||||||
|
the same shared unsaved-changes dialog. A browser tab/window unload remains a
|
||||||
|
browser-controlled confirmation because browsers do not permit a custom
|
||||||
|
modal at that boundary.
|
||||||
|
- `TableActionGroup` receives the table's stable action set. Use `disabled` and
|
||||||
|
`disabledReason` for row state; omit an action only when that action does not
|
||||||
|
belong to the table. `minimumSlots` reserves trailing positions for an empty
|
||||||
|
row. `DataGridEmptyAction` does this for the standard add/move/remove layout.
|
||||||
|
- A paginated `DataGrid` has exactly one query owner. Client mode receives the
|
||||||
|
complete logical row set and applies filtering and sorting before slicing a
|
||||||
|
page. Server mode receives only the loaded page, requires `onQueryChange`,
|
||||||
|
and the backend applies every emitted filter/sort before pagination while
|
||||||
|
returning `totalRows` for the filtered result. Server list filters declare
|
||||||
|
their complete option domain instead of deriving it from the loaded page.
|
||||||
|
External filter affordances such as summary-count shortcuts update the
|
||||||
|
grid's `query` contract; the grid header controls and backend query therefore
|
||||||
|
always display and execute the same filter state.
|
||||||
|
- Feedback and confirmation use `Dialog`, `ConfirmDialog`, or
|
||||||
|
`DismissibleAlert`. They never fall back to `window.alert`.
|
||||||
|
|
||||||
|
### DUE-012: Rich HTML Editing Contract
|
||||||
|
|
||||||
|
Decision: modules that edit persisted HTML use the central
|
||||||
|
`WysiwygEditor` exported from `@govoplan/core-webui/wysiwyg`.
|
||||||
|
|
||||||
|
- The dedicated subpath is intentional: the editor and its engine remain a
|
||||||
|
shared Core contract without adding their code to module combinations that
|
||||||
|
never consume rich-text editing.
|
||||||
|
- Consumers provide controlled HTML and domain-specific token labels. The
|
||||||
|
editor owns visual/source switching, formatting, links, images, safe URL
|
||||||
|
handling, and atomic inline token rendering; it does not own template
|
||||||
|
semantics or persistence.
|
||||||
|
- Existing HTML outside the supported visual subset opens in source mode.
|
||||||
|
Rendering the value must not rewrite it, and users receive an explicit
|
||||||
|
warning before choosing the visual surface.
|
||||||
|
- Domain placeholders remain their original serialized text. Atomic token
|
||||||
|
presentation is an editing aid only, so backend renderers and existing
|
||||||
|
templates do not need a new storage format.
|
||||||
|
|
||||||
|
#### FieldLabel Omission Register
|
||||||
|
|
||||||
|
Every Core field surface that intentionally does not render `FieldLabel` is
|
||||||
|
listed here. Module repositories keep an equivalent register in their durable
|
||||||
|
UI documentation until a central cross-repository audit is available.
|
||||||
|
|
||||||
|
| Core scope | Why `FieldLabel` is omitted | Accessible/context label source |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `PasswordField`, `ColorPickerField`, `DateField`, `TimeField`, and `DateTimeField` input internals | These are label-neutral composite primitives and are placed inside `FormField`/`FieldLabel` by the consuming form. Rendering another label inside the primitive would duplicate it. `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. |
|
||||||
|
| `EmailAddressInput` compact Name and Email fields | These two conventional fields are self-explanatory in the compact address popover; richer address guidance belongs to the enclosing field. | Visible native labels; the free-form editor also has a descriptive `aria-label`. |
|
||||||
|
| `DataGrid` page-size, filter, and inline cell editors | The surrounding column header/filter heading supplies field context; repeating a labelled help marker in every cell would add noise. | Column header, filter heading/native label, or generated cell `aria-label`. |
|
||||||
|
| Retention-policy value controls | `PolicyRow` owns the field label, help, effective value, and provenance for its control. | The containing `PolicyRow` label/help contract. |
|
||||||
|
|
||||||
|
#### Alert Exception Register
|
||||||
|
|
||||||
|
No `window.alert` or global `alert` exception is authorized.
|
||||||
|
|
||||||
|
## Implementation Sequence
|
||||||
|
|
||||||
|
| Phase | Scope | Output |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 0 | UX inventory | List every admin/settings/configuration surface, classify it, and record whether it violates a binding decision. |
|
||||||
|
| 1 | Core primitives | Shared wizard shell, advanced panel, help affordance, blocker callout, problem list, review step, and discovery/test result components. |
|
||||||
|
| 2 | File connectors | Adaptive create/edit for provider/server, credential, discovery/test, ownership/policy, plus optional assisted wizard later. |
|
||||||
|
| 3 | Mail servers | Same adaptive pattern as files, adapted to server/credential/policy and test-send/test-login behavior. |
|
||||||
|
| 4 | Policy/retention editors | Effective value first, provenance visible, override/edit in modal, blocked edits explained. |
|
||||||
|
| 5 | Module/package operations | Step-based install/uninstall flow with preflight, maintenance, daemon handoff, migration, and rollback explanation. |
|
||||||
|
| 6 | Remaining settings/admin screens | Apply inventory findings by priority and remove one-off layouts. |
|
||||||
|
|
||||||
|
## Current Surface Inventory
|
||||||
|
|
||||||
|
This is the phase-0 inventory baseline. It should be extended as each screen is
|
||||||
|
converted or reviewed.
|
||||||
|
|
||||||
|
| Surface | Repository | UX State | Next Action |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 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` | 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` | 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
|
||||||
|
|
||||||
|
| Surface | Current Risk | Expected Pattern |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| File connectors | Too many technical fields and unclear setup order. | Adaptive create/edit form with optional assisted wizard, discovery/test, credential binding, policy review, and advanced protocol panel. |
|
||||||
|
| Mail servers | Similar server/credential/policy concepts risk diverging from files. | Same tree/list and adaptive server/credential/policy model as file connectors. |
|
||||||
|
| Connector credentials | Security-sensitive details can overwhelm users. | Separate credential flow with secret-reference language and test result explanation. |
|
||||||
|
| Policy and effective settings | Users need to know why a value is inherited or locked. | Effective row first, source/provenance, local override action, actionable blocked reason. |
|
||||||
|
| Module install/uninstall | Operationally risky, currently inherently technical. | Operator wizard with preflight, maintenance, daemon handoff, review, and rollback explanation. |
|
||||||
|
| Configuration packages | Could become package JSON editing. | Package catalog/import wizard using provider data requirements and problem lists. |
|
||||||
|
| Retention/privacy | High-risk settings need explanation and provenance. | Layered editor with plain-language consequences and review. |
|
||||||
|
| 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, explicit inherit/reset semantics, effective-source provenance, and no double-click navigation traps. |
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
|
||||||
|
Every new or changed admin/configuration surface should answer:
|
||||||
|
|
||||||
|
- What is the common path, and is it visible without noise?
|
||||||
|
- Which fields are advanced, and are they collapsed by default?
|
||||||
|
- Is every configuration value editable through typed controls?
|
||||||
|
- Does the flow avoid JSON as the primary editor?
|
||||||
|
- 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
|
||||||
|
changes?
|
||||||
|
- Does the action surface show consequence, reversibility, and audit evidence
|
||||||
|
when rights, duties, records, money, communication, external systems, or
|
||||||
|
workflow state are affected?
|
||||||
|
- Does the surface use every applicable central Core component? If it contains
|
||||||
|
a custom component, is the product-owner authorization, narrow purpose,
|
||||||
|
rationale, scope, and non-duplication evidence recorded?
|
||||||
|
- Is a collection-wide create action in the collection heading rather than
|
||||||
|
duplicated in a persistent side panel?
|
||||||
|
- Are logical sections, tabular collections, boolean settings, and repeatable
|
||||||
|
people/contact rows composed with `Card`, `DataGrid`, `ToggleSwitch`, and one
|
||||||
|
structured row per person respectively?
|
||||||
|
- Does every non-self-explanatory field use `FieldLabel`, and is every omission
|
||||||
|
recorded with its rationale and accessible-name source?
|
||||||
|
- Do explicit Discard and dirty navigation use the shared unsaved-changes
|
||||||
|
registration/dialog rather than a page-local confirmation?
|
||||||
|
- Does every row retain the table's action set in the same order, disabling
|
||||||
|
unavailable actions and reserving the same empty-row slots?
|
||||||
|
- Is feedback rendered with a central dialog/alert component, with no
|
||||||
|
unauthorized `window.alert` or global `alert` call?
|
||||||
|
- If automation is involved, can the user see the trigger, system actor,
|
||||||
|
observed effects, and failure/manual-intervention state?
|
||||||
|
- Are technical details available without being the first thing the user sees?
|
||||||
|
|
||||||
|
## Revision Rule
|
||||||
|
|
||||||
|
When a UX decision changes:
|
||||||
|
|
||||||
|
1. Update this ledger.
|
||||||
|
2. Update the affected shared components.
|
||||||
|
3. Update existing screens listed in the impact index.
|
||||||
|
4. Add or update Gitea issues for any remaining surfaces that still follow the
|
||||||
|
old decision.
|
||||||
|
5. Sync the wiki.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Dependency Audit - 2026-07-09
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
GOVOPLAN_CORE_ROOT=/mnt/DATA/git/govoplan-core bash tools/checks/check-dependency-audits.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Status: remediated.
|
||||||
|
|
||||||
|
Initial result:
|
||||||
|
|
||||||
|
- Python audit failed: 24 advisories were reported across `cryptography`,
|
||||||
|
`pip`, `python-multipart`, `pyzipper`, and `starlette`.
|
||||||
|
- npm production audit passed: `npm audit --omit=dev` reported 0
|
||||||
|
vulnerabilities.
|
||||||
|
|
||||||
|
Python findings:
|
||||||
|
|
||||||
|
| Package | Installed | Advisory count | Minimum reported fix |
|
||||||
|
| --- | ---: | ---: | --- |
|
||||||
|
| `cryptography` | `44.0.0` | 5 | `48.0.1` |
|
||||||
|
| `pip` | `26.0.1` | 3 | `26.1.2` |
|
||||||
|
| `python-multipart` | `0.0.17` | 7 | `0.0.31` |
|
||||||
|
| `pyzipper` | `0.3.6` | 1 | `0.4.0` |
|
||||||
|
| `starlette` | `0.41.3` | 8 | `1.3.1` |
|
||||||
|
|
||||||
|
Private GovOPlaN packages were skipped by `pip-audit` because they are not
|
||||||
|
published on PyPI. That is expected for local editable development installs.
|
||||||
|
|
||||||
|
The remediation below upgrades those dependencies and records the compatibility
|
||||||
|
checks run against core, files, and campaign behavior.
|
||||||
|
|
||||||
|
## Remediation
|
||||||
|
|
||||||
|
Remediation applied on 2026-07-09:
|
||||||
|
|
||||||
|
- upgraded core's FastAPI floor to `fastapi>=0.139,<1`, resolving Starlette to
|
||||||
|
`starlette==1.3.1`
|
||||||
|
- upgraded core's cryptography floor to `cryptography>=48.0.1,<50`, resolving
|
||||||
|
to `cryptography==49.0.0`
|
||||||
|
- declared the files module upload parser dependency as
|
||||||
|
`python-multipart>=0.0.31,<1`, resolving to `python-multipart==0.0.32`
|
||||||
|
- upgraded the campaign ZIP dependency to `pyzipper>=0.4,<1`, resolving to
|
||||||
|
`pyzipper==0.4.0`
|
||||||
|
- upgraded the local audit environment to `pip==26.1.2`
|
||||||
|
- removed the obsolete local mailer-module editable install
|
||||||
|
from the audit environment so the audit reflects the split module product
|
||||||
|
|
||||||
|
Post-remediation result:
|
||||||
|
|
||||||
|
- `bash tools/checks/check-dependency-audits.sh`: passed, no known Python
|
||||||
|
vulnerabilities found and npm production audit reported 0 vulnerabilities.
|
||||||
|
- `python -m pip check`: passed.
|
||||||
|
- `bash tools/checks/check-module-matrix.sh`: passed.
|
||||||
|
- `python -m unittest tests.test_api_smoke`: passed.
|
||||||
|
- campaign encrypted/plain ZIP smoke with `pyzipper==0.4.0`: passed.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `pip-audit` still reports private GovOPlaN packages as skipped because they
|
||||||
|
are not published on PyPI. That is expected for editable local development
|
||||||
|
installs.
|
||||||
|
- `httpx2>=2.5,<3` is included in development requirements so Starlette's
|
||||||
|
`TestClient` uses the non-deprecated backend. `httpx==0.28.1` remains in dev
|
||||||
|
requirements for tests that mock connector HTTP responses directly.
|
||||||
|
- Split-module API routers now use Starlette's renamed
|
||||||
|
`HTTP_422_UNPROCESSABLE_CONTENT` status constant. This preserves the 422
|
||||||
|
status code while avoiding the deprecated `HTTP_422_UNPROCESSABLE_ENTITY`
|
||||||
|
alias.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user