Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
|
||||
+19
@@ -136,6 +136,25 @@ dist
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# Local WebUI test/build scratch directories
|
||||
.component-test-build/
|
||||
.file-drop-test-build/
|
||||
.module-test-build/
|
||||
.policy-test-build/
|
||||
.template-preview-test-build/
|
||||
.import-test-build/
|
||||
webui/.component-test-build/
|
||||
webui/.file-drop-test-build/
|
||||
webui/.module-test-build/
|
||||
webui/.policy-test-build/
|
||||
webui/.template-preview-test-build/
|
||||
webui/.import-test-build/
|
||||
webui/dist-conformance/
|
||||
webui/test-results/
|
||||
|
||||
# Security audit reports
|
||||
audit-reports/
|
||||
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
||||
@@ -6,6 +6,7 @@ This repository is the platform runner and shared core for GovOPlaN. It owns the
|
||||
|
||||
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`
|
||||
@@ -18,9 +19,9 @@ Use targeted commands first:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
./.venv/bin/python -m govoplan_core.devserver --smoke --no-reload
|
||||
./.venv/bin/python -m unittest tests.test_module_system
|
||||
./.venv/bin/python -m unittest tests.test_api_smoke.ApiSmokeTests.test_mailbox_message_listing_reports_total_count
|
||||
/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:
|
||||
@@ -35,8 +36,8 @@ PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versi
|
||||
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-core
|
||||
./scripts/check-focused.sh
|
||||
cd /mnt/DATA/git/govoplan
|
||||
tools/checks/check-focused.sh
|
||||
```
|
||||
|
||||
## Working Rules
|
||||
@@ -44,5 +45,8 @@ cd /mnt/DATA/git/govoplan-core
|
||||
- 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 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
|
||||
|
||||
@@ -9,37 +13,48 @@ Core owns:
|
||||
- `govoplan_core.server.app:app`, the FastAPI entry point used by uvicorn
|
||||
- `GovoplanServerConfig`, module discovery, registry validation, and route aggregation
|
||||
- SQLAlchemy base/session helpers and module migration registration
|
||||
- tenant/account/session/RBAC/governance/audit models and services
|
||||
- core API routes for auth, admin, platform metadata, audit, and system health
|
||||
- kernel APIs for platform metadata, module lifecycle, health, and development diagnostics
|
||||
- `@govoplan/core-webui`, including login, CSRF/API helpers, shell layout, generic UI components, IconRail, DataGrid, access boundaries, and module route/nav contracts
|
||||
|
||||
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
|
||||
|
||||
Canonical policy documents live in `docs/`:
|
||||
|
||||
- [RBAC_MANIFEST.md](docs/RBAC_MANIFEST.md)
|
||||
- [SYSTEM_GOVERNANCE_MANIFEST.md](docs/SYSTEM_GOVERNANCE_MANIFEST.md)
|
||||
- [DOCUMENTATION_MAP.md](docs/DOCUMENTATION_MAP.md)
|
||||
- [ACCESS_RBAC_MODEL.md](docs/ACCESS_RBAC_MODEL.md)
|
||||
- [GOVERNANCE_MODEL.md](docs/GOVERNANCE_MODEL.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
|
||||
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
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 `access,campaigns,files,mail`; set `ENABLED_MODULES` explicitly when testing a smaller module permutation.
|
||||
Run the platform server from core through the module-aware development runner. The default config reads `ENABLED_MODULES` and discovers installed module entry points. Local development defaults to the modules listed by `govoplan_core.settings.Settings.enabled_modules`, including Views when its package is installed; set `ENABLED_MODULES` explicitly when testing a smaller module permutation.
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
./.venv/bin/python -m govoplan_core.devserver \
|
||||
/mnt/DATA/git/govoplan/.venv/bin/python -m govoplan_core.devserver \
|
||||
--host 127.0.0.1 \
|
||||
--port 8000
|
||||
```
|
||||
@@ -48,29 +63,84 @@ For example, to test campaign without files or mail:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
ENABLED_MODULES=access,campaigns ./.venv/bin/python -m govoplan_core.devserver \
|
||||
ENABLED_MODULES=access,campaigns /mnt/DATA/git/govoplan/.venv/bin/python -m govoplan_core.devserver \
|
||||
--host 127.0.0.1 \
|
||||
--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 default development SQLite database lives at `runtime/multimailer-dev.db`, alongside other local runtime state.
|
||||
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.
|
||||
|
||||
If the configured local SQLite database is missing or empty, `govoplan_core.devserver` enables the development bootstrap before loading settings. This creates the schema and the default development login on startup. Explicitly setting `DEV_BOOTSTRAP_ENABLED=false` disables this convenience. Production deployments should use migrations and managed database provisioning instead.
|
||||
To run the production-like local profile with PostgreSQL, Redis, a Celery
|
||||
worker, explicit module configuration, and persistent local file storage:
|
||||
|
||||
To verify the effective runtime paths and missing-SQLite bootstrap without starting uvicorn, run the smoke mode:
|
||||
```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
|
||||
./.venv/bin/python -m govoplan_core.devserver --smoke --no-reload
|
||||
/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.
|
||||
|
||||
`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).
|
||||
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
|
||||
|
||||
@@ -84,6 +154,10 @@ PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versio
|
||||
|
||||
The local host links sibling module WebUI packages through local file dependencies and Vite filesystem allowances. Release builds should use `webui/package.release.json`, which points WebUI module packages at tagged git refs. See [RELEASE_DEPENDENCIES.md](docs/RELEASE_DEPENDENCIES.md).
|
||||
|
||||
Production builds lazy-load enabled module descriptors and enforce initial and
|
||||
asynchronous JavaScript budgets. See
|
||||
[WEBUI_BUNDLE_BUDGETS.md](docs/WEBUI_BUNDLE_BUDGETS.md).
|
||||
|
||||
## Module contract
|
||||
|
||||
Backend modules register through the `govoplan.modules` entry point and return a `ModuleManifest`. A manifest can contribute:
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
path_separator = os
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url = sqlite:///./runtime/multimailer-dev.db
|
||||
sqlalchemy.url = sqlite:///./runtime/govoplan-dev.db
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
@@ -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")
|
||||
+2
-2
@@ -94,10 +94,10 @@ def _scrub_policy_column(table_name: str) -> None:
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
tables = set(inspector.get_table_names())
|
||||
for table_name in ("system_settings", "tenants"):
|
||||
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 ("users", "groups", "campaigns"):
|
||||
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)
|
||||
|
||||
+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.create_foreign_key(
|
||||
op.f("fk_campaign_versions_locked_by_user_id_users"),
|
||||
"users",
|
||||
"access_users",
|
||||
["locked_by_user_id"],
|
||||
["id"],
|
||||
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("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(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
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("created_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(["owner_group_id"], ["groups.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["owner_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["owner_group_id"], ["access_groups.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["owner_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
|
||||
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"]:
|
||||
@@ -80,9 +80,9 @@ def upgrade() -> None:
|
||||
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"], 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(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
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("created_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(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
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_blob_id"], ["file_blobs.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.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("created_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(["owner_group_id"], ["groups.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["owner_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["owner_group_id"], ["access_groups.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["owner_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
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.create_foreign_key(
|
||||
"fk_campaign_versions_user_locked_by_user_id_users",
|
||||
"users",
|
||||
"access_users",
|
||||
["user_locked_by_user_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
+50
-50
@@ -62,25 +62,25 @@ def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
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
|
||||
# tables while leaving existing tables unaltered. Repair that known drift by
|
||||
# removing only empty, unreferenced administration tables before applying the
|
||||
# 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:
|
||||
count = bind.execute(sa.text("SELECT COUNT(*) FROM system_role_assignments")).scalar_one()
|
||||
if "account_id" not in user_columns and "access_system_role_assignments" in tables:
|
||||
count = bind.execute(sa.text("SELECT COUNT(*) FROM access_system_role_assignments")).scalar_one()
|
||||
if count:
|
||||
raise RuntimeError("Cannot reconcile non-empty create_all system_role_assignments table")
|
||||
op.drop_table("system_role_assignments")
|
||||
tables.remove("system_role_assignments")
|
||||
if "account_id" not in user_columns and "accounts" in tables:
|
||||
count = bind.execute(sa.text("SELECT COUNT(*) FROM accounts")).scalar_one()
|
||||
op.drop_table("access_system_role_assignments")
|
||||
tables.remove("access_system_role_assignments")
|
||||
if "account_id" not in user_columns and "access_accounts" in tables:
|
||||
count = bind.execute(sa.text("SELECT COUNT(*) FROM access_accounts")).scalar_one()
|
||||
if count:
|
||||
raise RuntimeError("Cannot reconcile non-empty create_all accounts table")
|
||||
op.drop_table("accounts")
|
||||
op.drop_table("access_accounts")
|
||||
|
||||
op.create_table(
|
||||
"accounts",
|
||||
"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),
|
||||
@@ -95,29 +95,29 @@ def upgrade() -> None:
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
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("default_locale", sa.String(length=20), nullable=False, server_default="en"))
|
||||
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("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("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()))
|
||||
|
||||
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))
|
||||
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))
|
||||
|
||||
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()
|
||||
|
||||
accounts_by_email: dict[str, str] = {}
|
||||
@@ -155,7 +155,7 @@ def upgrade() -> None:
|
||||
first_system_owner_account_id = account_id
|
||||
|
||||
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("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),
|
||||
@@ -166,54 +166,54 @@ def upgrade() -> None:
|
||||
bind.execute(accounts_table.insert(), list(account_rows.values()))
|
||||
for row in users:
|
||||
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"]},
|
||||
)
|
||||
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.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"])
|
||||
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.create_foreign_key("fk_auth_sessions_account_id_accounts", "accounts", ["account_id"], ["id"], ondelete="CASCADE")
|
||||
op.create_index(op.f("ix_auth_sessions_account_id"), "auth_sessions", ["account_id"])
|
||||
batch_op.create_foreign_key("fk_auth_sessions_account_id_accounts", "access_accounts", ["account_id"], ["id"], ondelete="CASCADE")
|
||||
op.create_index(op.f("ix_access_auth_sessions_account_id"), "access_auth_sessions", ["account_id"])
|
||||
|
||||
op.create_table(
|
||||
"system_role_assignments",
|
||||
"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"], ["accounts.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["role_id"], ["roles.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["account_id"], ["access_accounts.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["role_id"], ["access_roles.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
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_system_role_assignments_role_id"), "system_role_assignments", ["role_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_access_system_role_assignments_role_id"), "access_system_role_assignments", ["role_id"])
|
||||
|
||||
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("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("created_at", sa.DateTime(timezone=True)), sa.column("updated_at", sa.DateTime(timezone=True)),
|
||||
)
|
||||
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()
|
||||
tenant_role_ids: dict[tuple[str, str], str] = {}
|
||||
for tenant_id in tenant_ids:
|
||||
for slug, definition in definitions.items():
|
||||
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},
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
@@ -253,7 +253,7 @@ def upgrade() -> None:
|
||||
|
||||
op.create_index(
|
||||
"uq_roles_system_slug",
|
||||
"roles",
|
||||
"access_roles",
|
||||
["slug"],
|
||||
unique=True,
|
||||
sqlite_where=sa.text("tenant_id IS NULL"),
|
||||
@@ -267,11 +267,11 @@ def upgrade() -> None:
|
||||
continue
|
||||
owner_role_id = tenant_role_ids[(row["tenant_id"], "owner")]
|
||||
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()
|
||||
if not exists:
|
||||
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})
|
||||
|
||||
# Bootstrap rule for existing installations: the earliest active legacy
|
||||
@@ -284,40 +284,40 @@ def upgrade() -> None:
|
||||
), None)
|
||||
if first_system_owner_account_id:
|
||||
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})
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("uq_roles_system_slug", table_name="roles")
|
||||
op.drop_index(op.f("ix_system_role_assignments_role_id"), table_name="system_role_assignments")
|
||||
op.drop_index(op.f("ix_system_role_assignments_account_id"), table_name="system_role_assignments")
|
||||
op.drop_table("system_role_assignments")
|
||||
op.drop_index("uq_roles_system_slug", table_name="access_roles")
|
||||
op.drop_index(op.f("ix_access_system_role_assignments_role_id"), table_name="access_system_role_assignments")
|
||||
op.drop_index(op.f("ix_access_system_role_assignments_account_id"), table_name="access_system_role_assignments")
|
||||
op.drop_table("access_system_role_assignments")
|
||||
|
||||
op.drop_index(op.f("ix_auth_sessions_account_id"), table_name="auth_sessions")
|
||||
with op.batch_alter_table("auth_sessions") as batch_op:
|
||||
op.drop_index(op.f("ix_access_auth_sessions_account_id"), table_name="access_auth_sessions")
|
||||
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_column("account_id")
|
||||
|
||||
op.drop_index(op.f("ix_users_account_id"), table_name="users")
|
||||
with op.batch_alter_table("users") as batch_op:
|
||||
op.drop_index(op.f("ix_access_users_account_id"), table_name="access_users")
|
||||
with op.batch_alter_table("access_users") as batch_op:
|
||||
batch_op.drop_constraint("uq_users_tenant_account", type_="unique")
|
||||
batch_op.drop_constraint("fk_users_account_id_accounts", type_="foreignkey")
|
||||
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_builtin")
|
||||
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("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("default_locale")
|
||||
batch_op.drop_column("description")
|
||||
|
||||
op.drop_index(op.f("ix_accounts_normalized_email"), table_name="accounts")
|
||||
op.drop_table("accounts")
|
||||
op.drop_index(op.f("ix_access_accounts_normalized_email"), table_name="access_accounts")
|
||||
op.drop_table("access_accounts")
|
||||
+42
-33
@@ -16,6 +16,7 @@ revision = "9d0e1f2a3b4c"
|
||||
down_revision = "8c9d0e1f2a3b"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
_RECONCILE_CREATE_ALL_TABLES = ("admin_governance_template_assignments", "admin_governance_templates", "core_system_settings")
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
@@ -28,15 +29,16 @@ def upgrade() -> None:
|
||||
tables = set(inspector.get_table_names())
|
||||
|
||||
# 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:
|
||||
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:
|
||||
raise RuntimeError(f"Cannot reconcile non-empty create_all table {table_name}")
|
||||
op.drop_table(table_name)
|
||||
|
||||
op.create_table(
|
||||
"system_settings",
|
||||
"core_system_settings",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
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()),
|
||||
@@ -50,16 +52,23 @@ def upgrade() -> None:
|
||||
now = _now()
|
||||
bind.execute(sa.text(
|
||||
"""
|
||||
INSERT INTO system_settings
|
||||
INSERT INTO core_system_settings
|
||||
(id, default_locale, allow_tenant_custom_groups, allow_tenant_custom_roles,
|
||||
allow_tenant_api_keys, settings, created_at, updated_at)
|
||||
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(
|
||||
"governance_templates",
|
||||
"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),
|
||||
@@ -72,51 +81,51 @@ def upgrade() -> None:
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
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(
|
||||
"governance_template_assignments",
|
||||
"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, server_default="available"),
|
||||
sa.Column("created_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(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["template_id"], ["admin_governance_templates.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
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_governance_template_assignments_tenant_id"), "governance_template_assignments", ["tenant_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_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_roles", 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_required", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
batch_op.create_foreign_key(
|
||||
"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_required", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
batch_op.create_foreign_key(
|
||||
"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
|
||||
# read-only built-in auditor role to the newly introduced read scopes.
|
||||
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()
|
||||
if auditor:
|
||||
raw_permissions = auditor["permissions"] or []
|
||||
@@ -125,7 +134,7 @@ def upgrade() -> None:
|
||||
if scope not in permissions:
|
||||
permissions.append(scope)
|
||||
roles_table = sa.table(
|
||||
"roles",
|
||||
"access_roles",
|
||||
sa.column("id", sa.String),
|
||||
sa.column("permissions", sa.JSON),
|
||||
sa.column("updated_at", sa.DateTime(timezone=True)),
|
||||
@@ -138,26 +147,26 @@ def upgrade() -> None:
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_roles_system_template_id"), table_name="roles")
|
||||
with op.batch_alter_table("roles") as batch_op:
|
||||
op.drop_index(op.f("ix_access_roles_system_template_id"), table_name="access_roles")
|
||||
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_column("system_required")
|
||||
batch_op.drop_column("system_template_id")
|
||||
|
||||
op.drop_index(op.f("ix_groups_system_template_id"), table_name="groups")
|
||||
with op.batch_alter_table("groups") as batch_op:
|
||||
op.drop_index(op.f("ix_access_groups_system_template_id"), table_name="access_groups")
|
||||
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_column("system_required")
|
||||
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_custom_roles")
|
||||
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_governance_template_assignments_template_id"), table_name="governance_template_assignments")
|
||||
op.drop_table("governance_template_assignments")
|
||||
op.drop_index(op.f("ix_governance_templates_kind"), table_name="governance_templates")
|
||||
op.drop_table("governance_templates")
|
||||
op.drop_table("system_settings")
|
||||
op.drop_index(op.f("ix_admin_governance_template_assignments_tenant_id"), table_name="admin_governance_template_assignments")
|
||||
op.drop_index(op.f("ix_admin_governance_template_assignments_template_id"), table_name="admin_governance_template_assignments")
|
||||
op.drop_table("admin_governance_template_assignments")
|
||||
op.drop_index(op.f("ix_admin_governance_templates_kind"), table_name="admin_governance_templates")
|
||||
op.drop_table("admin_governance_templates")
|
||||
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("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(["tenant_id"], ["tenancy_tenants.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.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_created_by_user_id", "campaign_shares", ["created_by_user_id"])
|
||||
op.create_index("ix_campaign_shares_revoked_at", "campaign_shares", ["revoked_at"])
|
||||
if "roles" not in tables:
|
||||
if "access_roles" not in tables:
|
||||
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:
|
||||
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"])))},
|
||||
)
|
||||
|
||||
for slug, permissions in TENANT_ROLE_PERMISSIONS.items():
|
||||
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)},
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
for slug, permissions in SYSTEM_ROLE_PERMISSIONS.items():
|
||||
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()
|
||||
is_protected = slug == "system_owner"
|
||||
if existing:
|
||||
bind.execute(
|
||||
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"
|
||||
),
|
||||
{"slug": slug, "permissions": _json(permissions), "is_builtin": is_protected},
|
||||
@@ -199,7 +199,7 @@ def upgrade() -> None:
|
||||
name, description = names[slug]
|
||||
bind.execute(
|
||||
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) "
|
||||
"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))
|
||||
params = {f"action_{index}": action for index, action in enumerate(SYSTEM_ACTIONS)}
|
||||
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,
|
||||
)
|
||||
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:
|
||||
# ### 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('slug', sa.String(length=100), 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.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',
|
||||
sa.Column('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('created_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.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('groups',
|
||||
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('created_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.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_table('roles',
|
||||
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),
|
||||
@@ -64,12 +64,12 @@ def upgrade() -> None:
|
||||
sa.Column('permissions', 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(['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.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_table('users',
|
||||
op.create_index(op.f('ix_access_roles_tenant_id'), 'access_roles', ['tenant_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('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('created_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.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_users_tenant_id'), 'users', ['tenant_id'], unique=False)
|
||||
op.create_table('api_keys',
|
||||
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('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),
|
||||
@@ -97,13 +97,13 @@ def upgrade() -> None:
|
||||
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(['tenant_id'], ['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(['tenant_id'], ['tenancy_tenants.id'], name=op.f('fk_api_keys_tenant_id_tenants'), 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'))
|
||||
)
|
||||
op.create_index(op.f('ix_api_keys_prefix'), '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_api_keys_user_id'), 'api_keys', ['user_id'], unique=False)
|
||||
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('campaigns',
|
||||
sa.Column('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('created_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(['tenant_id'], ['tenants.id'], name=op.f('fk_campaigns_tenant_id_tenants'), ondelete='CASCADE'),
|
||||
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'], ['tenancy_tenants.id'], name=op.f('fk_campaigns_tenant_id_tenants'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_campaigns')),
|
||||
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.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'], ['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(['owner_user_id'], ['access_users.id'], name=op.f('fk_attachment_instances_owner_user_id_users'), ondelete='SET NULL'),
|
||||
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'))
|
||||
)
|
||||
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('created_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(['tenant_id'], ['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(['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'], ['tenancy_tenants.id'], name=op.f('fk_audit_log_tenant_id_tenants'), ondelete='CASCADE'),
|
||||
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'))
|
||||
)
|
||||
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.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(['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.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_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(['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'))
|
||||
)
|
||||
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_created_by_user_id'), table_name='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_api_keys_tenant_id'), table_name='api_keys')
|
||||
op.drop_index(op.f('ix_api_keys_prefix'), table_name='api_keys')
|
||||
op.drop_table('api_keys')
|
||||
op.drop_index(op.f('ix_users_tenant_id'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||
op.drop_table('users')
|
||||
op.drop_index(op.f('ix_roles_tenant_id'), table_name='roles')
|
||||
op.drop_table('roles')
|
||||
op.drop_index(op.f('ix_groups_tenant_id'), table_name='groups')
|
||||
op.drop_table('groups')
|
||||
op.drop_index(op.f('ix_access_api_keys_user_id'), table_name='access_api_keys')
|
||||
op.drop_index(op.f('ix_access_api_keys_tenant_id'), table_name='access_api_keys')
|
||||
op.drop_index(op.f('ix_access_api_keys_prefix'), table_name='access_api_keys')
|
||||
op.drop_table('access_api_keys')
|
||||
op.drop_index(op.f('ix_access_users_tenant_id'), table_name='access_users')
|
||||
op.drop_index(op.f('ix_access_users_email'), table_name='access_users')
|
||||
op.drop_table('access_users')
|
||||
op.drop_index(op.f('ix_access_roles_tenant_id'), table_name='access_roles')
|
||||
op.drop_table('access_roles')
|
||||
op.drop_index(op.f('ix_access_groups_tenant_id'), table_name='access_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_sha256'), table_name='attachment_blobs')
|
||||
op.drop_table('attachment_blobs')
|
||||
op.drop_index(op.f('ix_tenants_slug'), table_name='tenants')
|
||||
op.drop_table('tenants')
|
||||
op.drop_index(op.f('ix_tenancy_tenants_slug'), table_name='tenancy_tenants')
|
||||
op.drop_table('tenancy_tenants')
|
||||
# ### 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()
|
||||
inspector = sa.inspect(bind)
|
||||
tables = set(inspector.get_table_names())
|
||||
if "auth_sessions" in tables:
|
||||
columns = {column["name"] for column in inspector.get_columns("auth_sessions")}
|
||||
if "access_auth_sessions" in tables:
|
||||
columns = {column["name"] for column in inspector.get_columns("access_auth_sessions")}
|
||||
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:
|
||||
op.create_table(
|
||||
"mail_server_profiles",
|
||||
@@ -40,9 +40,9 @@ def upgrade() -> None:
|
||||
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"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["updated_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("tenant_id", "slug", name="uq_mail_server_profiles_tenant_slug"),
|
||||
)
|
||||
@@ -67,7 +67,7 @@ def downgrade() -> None:
|
||||
except Exception:
|
||||
pass
|
||||
op.drop_table("mail_server_profiles")
|
||||
if "auth_sessions" in inspector.get_table_names():
|
||||
columns = {column["name"] for column in inspector.get_columns("auth_sessions")}
|
||||
if "access_auth_sessions" in inspector.get_table_names():
|
||||
columns = {column["name"] for column in inspector.get_columns("access_auth_sessions")}
|
||||
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)
|
||||
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:
|
||||
continue
|
||||
columns = _columns(inspector, table_name)
|
||||
@@ -83,6 +83,6 @@ def downgrade() -> None:
|
||||
batch.drop_column("scope_type")
|
||||
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):
|
||||
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()
|
||||
inspector = sa.inspect(bind)
|
||||
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:
|
||||
continue
|
||||
if "settings" not in _columns(inspector, table_name):
|
||||
@@ -36,6 +36,6 @@ def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
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):
|
||||
op.drop_column(table_name, "settings")
|
||||
+22
-6
@@ -5,29 +5,45 @@ from logging.config import fileConfig
|
||||
from alembic import context
|
||||
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.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.registry import build_platform_registry
|
||||
from govoplan_core.settings import settings
|
||||
from govoplan_core.tenancy.scope import scope_registry
|
||||
|
||||
config = context.config
|
||||
database_url = config.attributes.get("database_url") or settings.database_url
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
|
||||
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():
|
||||
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(
|
||||
server_config.enabled_modules,
|
||||
manifest_factories=server_config.manifest_factories,
|
||||
enabled_modules,
|
||||
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))
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
+26
-4
@@ -24,6 +24,15 @@ 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"
|
||||
|
||||
@@ -40,25 +49,36 @@ The broad writable root reduces approval churn. The explicit project trust entri
|
||||
|
||||
Each active repository has an `AGENTS.md` file. These files define ownership, module boundaries, and focused commands for Codex. Keep durable project conventions there instead of repeating them in every prompt.
|
||||
|
||||
Documentation is part of the completion criteria for every behavior change. The
|
||||
owning module must update its manifest-driven `DocumentationTopic` contributions
|
||||
for affected user and administrator workflows, settings, permissions,
|
||||
limitations, and operational consequences. Feature documentation remains in the
|
||||
feature module; the optional `govoplan-docs` module projects those contributions
|
||||
without importing feature internals. Every module manifest must retain a static
|
||||
user and administrator baseline even when runtime providers add configured-state
|
||||
details.
|
||||
|
||||
Use `~/.codex/config.toml` for personal defaults, auth/runtime settings, writable roots, and trust decisions. Avoid checking in absolute-path writable roots or model preferences unless they are intentionally team-wide.
|
||||
|
||||
Use Gitea issues as the canonical backlog and state log. See `docs/GITEA_ISSUES.md` for label setup, TODO import, and Codex issue update commands. Durable docs should describe stable behavior; changing work state belongs on the issue.
|
||||
|
||||
## 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-core
|
||||
./scripts/check-focused.sh
|
||||
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
|
||||
./.venv/bin/python -m unittest tests.test_module_system
|
||||
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest tests.test_module_system
|
||||
|
||||
cd /mnt/DATA/git/govoplan-mail
|
||||
/mnt/DATA/git/govoplan-core/.venv/bin/python -m unittest discover -s tests
|
||||
/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
|
||||
@@ -70,4 +90,6 @@ PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versi
|
||||
- Avoid broad recursive scans and full builds unless the change warrants them.
|
||||
- Keep generated build/test folders ignored.
|
||||
- Keep optional module behavior behind core registry/capability/module metadata boundaries.
|
||||
- Run `tools/checks/check-manifest-shapes.py` after module behavior or manifest changes so user/admin documentation coverage remains complete.
|
||||
- Create or update Gitea issues for TODOs, follow-ups, blockers, and feature requests instead of keeping local backlog files.
|
||||
- Do not start persistent dev servers unless the user asks.
|
||||
|
||||
@@ -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,401 @@
|
||||
# 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(...)`
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## 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,73 @@
|
||||
# Contextual Help Contract
|
||||
|
||||
GovOPlaN exposes context-sensitive help through `F1` and the titlebar help
|
||||
control. The shell resolves a stable help identity from the focused control,
|
||||
its containing surface, and the current route. The Docs module then projects
|
||||
the best visible user or administrator topic for that identity.
|
||||
|
||||
## Resolution Order
|
||||
|
||||
The WebUI resolves help in this order:
|
||||
|
||||
1. an explicit `helpContextId` or `data-help-context-id` on the focused item
|
||||
2. the focused shared control's `interfaceId`, `helpTopicId`, and label key
|
||||
3. a containing dialog, card, administration section, or page surface
|
||||
4. the current registered route, including dynamic module routes
|
||||
5. a stable route-derived fallback when no explicit identity is available
|
||||
|
||||
Focused field and action contexts retain the page context as
|
||||
`fallback_context`. This lets Docs show a field-specific topic when one exists
|
||||
and otherwise open the owning page or module documentation instead of a generic
|
||||
help page.
|
||||
|
||||
## Documentation Lookup
|
||||
|
||||
Static `DocumentationTopic` contributions announce exact contexts through
|
||||
`metadata.help_contexts`. Core publishes that catalogue with the enabled module
|
||||
manifest, allowing the shell to link directly to an exact topic when possible.
|
||||
Docs still performs the authoritative audience, permission, configured-state,
|
||||
and documentation-type filtering.
|
||||
|
||||
Core also maps explicit route, navigation, settings, and View surface IDs to
|
||||
the module's static user or administrator documentation baseline. This makes a
|
||||
page association complete by default and gives every derived field/action
|
||||
context a useful fallback. Exact `metadata.help_contexts` remain the preferred
|
||||
authoring mechanism for consequential or unfamiliar controls.
|
||||
|
||||
When there is no exact topic, Docs resolves the page fallback and then the first
|
||||
visible topic owned by the module. If Docs is unavailable, the shell opens the
|
||||
hosted documentation with the same context parameters.
|
||||
|
||||
## Authoring Controls
|
||||
|
||||
Core shared controls expose stable help metadata. Prefer these props rather
|
||||
than adding custom `F1` listeners:
|
||||
|
||||
- `interfaceId` identifies a durable UI surface or action.
|
||||
- `helpContextId` identifies a documentation context when it differs from the
|
||||
interface identity.
|
||||
- `helpModuleId` identifies the documentation-owning module when a shared
|
||||
control is embedded in another module's page.
|
||||
- `helpTopicId` links directly to a module-owned documentation topic.
|
||||
- translated label keys provide deterministic field identities for ordinary
|
||||
`FormField`, `ToggleSwitch`, search, date/time, email, button, dialog, and card
|
||||
controls.
|
||||
- `TableActionGroup` action definitions carry the same identities so focused
|
||||
row actions can resolve consequence-specific help.
|
||||
- `PageLayout` owns the page help scope and documentation identity for ordinary
|
||||
headed pages. `WorkspaceLayout` owns the full-canvas workspace scope and its
|
||||
labelled primary/content panes; pages inside it use `PageLayout` in
|
||||
`workspace` mode and retain their own route-level help identity.
|
||||
|
||||
Module routes, public routes, settings sections, and administration sections
|
||||
may also declare `helpContextId` and `helpTopicId`. Each module must keep a
|
||||
static user/admin documentation baseline and should list its important route,
|
||||
workflow, setting, permission, and limitation identities in
|
||||
`metadata.help_contexts`.
|
||||
|
||||
## Boundary
|
||||
|
||||
Help identities describe presentation context; they are not authorization
|
||||
claims. Opening help never bypasses route or documentation permissions. Docs
|
||||
owns documentation projection, feature modules own their content, and Core owns
|
||||
focus capture, context resolution, and fallback routing.
|
||||
@@ -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,97 @@
|
||||
# Localization And Contextual Help Quality
|
||||
|
||||
## Reference Language
|
||||
|
||||
German (`de`) is GovOPlaN's first-class reference target. Every translation key
|
||||
used by a shipped WebUI must exist in German and English. German completeness is
|
||||
a release gate; English remains the source-code fallback language so existing
|
||||
literal labels and external developer APIs do not change semantics.
|
||||
|
||||
New installations and tenants default to German. Existing system, tenant, and
|
||||
user preferences are preserved. The available-language and policy model can
|
||||
still select another default or disable a package at the relevant scope.
|
||||
|
||||
Explicit high-risk help content and browser acceptance are tracked in
|
||||
[Core #284](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/284).
|
||||
|
||||
The platform inventory recognizes both inline locale objects and generated
|
||||
catalogs declared as `const de` / `const en`. Its strict mode requires both
|
||||
locales and reports `de` explicitly as the reference locale.
|
||||
|
||||
## Help Resolution
|
||||
|
||||
Every focusable field and action receives a stable derived F1 identity from the
|
||||
shared shell, even when the component has no dedicated help text. Resolution
|
||||
falls back from field/action to dialog or page and then to the module's visible
|
||||
documentation baseline.
|
||||
|
||||
Backend manifests publish explicit topic associations first. Core additionally
|
||||
associates declared route, navigation, settings, and View surface IDs with the
|
||||
module's static user or administrator documentation baseline. Feature modules
|
||||
should still add exact `metadata.help_contexts` entries for consequential,
|
||||
unfamiliar, policy-controlled, destructive, security-sensitive, or legally
|
||||
meaningful fields and actions.
|
||||
|
||||
The shared retention-policy editor exposes explicit contexts for each stored
|
||||
data category, audit-detail control, lower-level override switch, target
|
||||
selector, reload, and save action. The Policy module owns the matching German
|
||||
administrator guidance. Retention execution surfaces use separate contexts for
|
||||
dry-run, destructive apply, confirmation, and outcome review so F1 opens the
|
||||
consequence and recovery guidance closest to the focused control.
|
||||
Shared controls may set `helpModuleId` when their documentation owner differs
|
||||
from the containing page; the retention editor uses this to resolve Policy help
|
||||
from both administration and Campaign surfaces.
|
||||
|
||||
The shared reusable-credential manager keeps Access as its documentation owner
|
||||
and publishes exact contexts for credential kind, secret replacement/removal,
|
||||
module and server restrictions, lower-scope visibility, activation, save, and
|
||||
irreversible deletion. This ensures F1 explains secret custody and the effect on
|
||||
dependent connections from system, tenant, group, user, and personal surfaces.
|
||||
|
||||
The source inventory treats literal `helpContextId` and
|
||||
`data-help-context-id` declarations as authored help associations, including a
|
||||
native control nested in `FormField`. Dynamic context expressions remain
|
||||
separate evidence and generic derived fallbacks remain in the richer-help
|
||||
candidate queue.
|
||||
|
||||
The generated `help_review_candidates` list is therefore a content-depth queue,
|
||||
not a list of controls on which F1 cannot work. It should prioritize:
|
||||
|
||||
1. effect, deletion, delivery, retention, disclosure, encryption, and recovery;
|
||||
2. identity, representation, mandate, institutional context, and purpose;
|
||||
3. valid-time versus recorded-time selection;
|
||||
4. provider authority, synchronization, conflict, and outcome unknown;
|
||||
5. fields whose consequences are not evident from their label.
|
||||
|
||||
The shared browser conformance journey mounts the production Help menu and
|
||||
resolver. It proves that F1 uses the focused control rather than only the page,
|
||||
maps an exact retention action to Policy-owned administrator documentation,
|
||||
retains the page context as fallback for derived actions, exposes an accessible
|
||||
modal at narrow widths, closes with Escape, and restores focus to the triggering
|
||||
control. Module journeys should add their own exact high-risk mappings; they do
|
||||
not need to reimplement the keyboard or dialog mechanics.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan
|
||||
/mnt/DATA/git/govoplan/.venv/bin/python \
|
||||
tools/inventory/platform-interface-inventory.py \
|
||||
--strict --strict-declarations --strict-endpoints
|
||||
```
|
||||
|
||||
The check must report:
|
||||
|
||||
- reference locale `de` present and complete;
|
||||
- no used key missing from `de` or `en`;
|
||||
- every field has a resolvable F1 context;
|
||||
- no duplicate stable IDs;
|
||||
- no undeclared public WebUI surface;
|
||||
- no stale runtime route or endpoint declaration.
|
||||
|
||||
Browser acceptance is part of the focused workspace gate and can be run alone:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core/webui
|
||||
npm run test:conformance
|
||||
```
|
||||
+1505
-12
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 mode, the lower-level override switch for that mode, 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.
|
||||
+919
-24
@@ -1,64 +1,959 @@
|
||||
# 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:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
cd /mnt/DATA/git/govoplan
|
||||
./.venv/bin/python -m pip install -r requirements-dev.txt
|
||||
```
|
||||
|
||||
Release install from a core checkout plus tagged module repositories:
|
||||
Release install from the meta checkout plus tagged module repositories:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
cd /mnt/DATA/git/govoplan
|
||||
./.venv/bin/python -m pip install -r requirements-release.txt
|
||||
```
|
||||
|
||||
`.[server]` is resolved relative to the current working directory. If you create the virtualenv elsewhere, still run the install command from the core checkout:
|
||||
`../govoplan-core[server]` is resolved relative to the meta requirements file.
|
||||
If you create the virtualenv elsewhere, still run the install command from the
|
||||
meta checkout:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
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:
|
||||
`requirements-release.txt` pins the module repositories to the release tag.
|
||||
Update those refs when cutting a release:
|
||||
|
||||
```text
|
||||
govoplan-files git@git.add-ideas.de:add-ideas/govoplan-files.git v0.1.1
|
||||
govoplan-mail git@git.add-ideas.de:add-ideas/govoplan-mail.git v0.1.1
|
||||
govoplan-campaign git@git.add-ideas.de:add-ideas/govoplan-campaign.git v0.1.1
|
||||
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
|
||||
## WebUI Packages
|
||||
|
||||
Local development uses `webui/package.json`, which may point at sibling module checkouts while active development is happening.
|
||||
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:
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
### Release Lockfile Strategy
|
||||
|
||||
The supported release composition currently is the full Multi Seal Mail product: core plus files, mail, and campaign. 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.
|
||||
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.
|
||||
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
|
||||
|
||||
- Keep Python package versions, WebUI package versions, and git tags aligned.
|
||||
- Tag core, files, mail, and campaign repositories together.
|
||||
- Update `requirements-release.txt` and `webui/package.release.json` when the release tag changes.
|
||||
- Generate the committed full-product release lockfile from `package.release.json` in a clean build workspace.
|
||||
- Add separate release manifest/lockfile pairs only for module compositions that are shipped as their own products.
|
||||
- Tag core, access, admin, tenancy, policy, audit, files, mail, campaign,
|
||||
calendar, and scaffold module repositories together.
|
||||
- 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.
|
||||
|
||||
@@ -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,179 +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;
|
||||
- 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.
|
||||
|
||||
## 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,401 @@
|
||||
# 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.
|
||||
- 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