Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa91063211 | ||
|
|
fa2d5d40dd | ||
|
|
6ccef162f6 | ||
|
|
48dac139a5 | ||
|
|
a090e5af20 | ||
|
|
0c1358b862 | ||
|
|
a9035c4c3b | ||
|
|
137c7c005f | ||
|
|
8eeea968f2 | ||
|
|
0ca6568005 | ||
|
|
af90db44c9 | ||
|
|
5de46e9c0e | ||
|
|
1d9b677c1b | ||
|
|
54178ee56c | ||
|
|
10e7597612 | ||
|
|
142ccbc587 | ||
|
|
f75ad48d78 | ||
|
|
5a9e8f79f9 | ||
|
|
fbea74a74b | ||
|
|
925dc33696 | ||
|
|
4b0737e1cd | ||
|
|
4f4007aff1 | ||
|
|
8e687c4420 | ||
|
|
604f20eed7 | ||
|
|
6a2da94e47 | ||
|
|
e121ca900e | ||
|
|
79629c5a2c | ||
|
|
026e451aa4 | ||
|
|
f11c675d11 | ||
|
|
0fae09ba3c | ||
|
|
8f642bd618 | ||
|
|
6643c8fc1e | ||
|
|
fd90b60430 | ||
|
|
0aae6f0539 | ||
|
|
be7b79612c | ||
|
|
557c77670b | ||
|
|
d277218784 | ||
|
|
cf16a7b27a | ||
|
|
51bf14f376 | ||
|
|
8d9bcfd8b5 | ||
|
|
3c7a593f63 | ||
|
|
9d1352ba30 | ||
|
|
4cf2bfeb3e | ||
|
|
94c94fefb4 | ||
|
|
d600bca374 | ||
|
|
ffaab543d2 | ||
|
|
41db78c201 | ||
|
|
c042244da8 | ||
|
|
5a2e99f496 | ||
|
|
8a925782ab | ||
|
|
7685a103e8 | ||
|
|
ee5c881df9 | ||
|
|
6814a41ae4 | ||
|
|
dd7ad4d9c7 | ||
|
|
934db6d44b | ||
|
|
d307e29145 | ||
|
|
ff88142471 | ||
|
|
887e9beb9e | ||
|
|
e6457b3f6b | ||
|
|
eb0c01c5d2 | ||
|
|
40cc012124 | ||
|
|
44196f5620 | ||
|
|
9ceb1b8c22 | ||
|
|
32c234fbdb | ||
|
|
d65d7a8e5f | ||
|
|
b5f5be15f6 | ||
|
|
f5949427cc | ||
|
|
5d1287735e | ||
|
|
7ea0cb8655 | ||
|
|
b553513c9f | ||
|
|
b5a4eb177a | ||
|
|
f1a5be2a93 | ||
|
|
add7a99f6d | ||
|
|
982ef636b8 | ||
|
|
9aad49f16d | ||
|
|
2b5c14385d | ||
|
|
bca3e46293 | ||
|
|
f09d2bf9df | ||
|
|
702421be48 | ||
|
|
bb471df21c | ||
|
|
bfb0d7d7c9 | ||
|
|
1974bf1a2b | ||
|
|
0c9bf6758c | ||
|
|
25da7d49a9 | ||
|
|
40c10089ab | ||
|
|
d6e7c8b0b1 | ||
|
|
5bc7d748f8 | ||
|
|
7117673ecc | ||
|
|
14351b0c94 | ||
|
|
ad57fad1ea | ||
|
|
fa32cca03f | ||
|
|
2d0551a845 | ||
|
|
bb84122061 | ||
|
|
b823a22b9b | ||
|
|
70fc6da811 | ||
|
|
729b84d3af | ||
|
|
b962f6756e | ||
|
|
79d00b84e3 | ||
|
|
842be5edb5 | ||
|
|
7e59a7f2b3 | ||
|
|
5bfbe9a887 | ||
|
|
01f91154e0 | ||
|
|
6c2940aebc | ||
|
|
670693bde8 | ||
|
|
bca6a7c8aa | ||
|
|
21c1fa49b6 | ||
|
|
435b924fd9 | ||
|
|
af5c6af0e7 | ||
|
|
c6ef644842 | ||
|
|
5783d43547 | ||
|
|
e4d2d10c7e | ||
|
|
fe62fd4644 | ||
|
|
9ecdc6d713 | ||
|
|
ca35aad286 | ||
|
|
d6255f9f8f | ||
|
|
b58c9c55cf | ||
|
|
3c4bcc28f1 | ||
|
|
972c681650 | ||
|
|
2b4eb0151f | ||
|
|
7192d32e65 | ||
|
|
b65b48832b | ||
|
|
4cb334c912 | ||
|
|
6ebb299d6c | ||
|
|
42b5019464 | ||
|
|
4c0e6435ab | ||
|
|
e37d8fee94 | ||
|
|
50a8d459e7 | ||
|
|
5ee85d07d6 | ||
|
|
cf01545806 | ||
|
|
1884274f8d | ||
|
|
5211e07d0b | ||
|
|
7b8072d049 | ||
|
|
5b55f59a92 | ||
|
|
f0898fcdee | ||
|
|
9b88ae388b | ||
|
|
6970bf7457 | ||
|
|
47e106684d | ||
|
|
9e219bc4d3 | ||
|
|
ea436a513f | ||
|
|
e7c84e3227 | ||
|
|
cf7afe9dda | ||
|
|
ca8a8c5111 | ||
|
|
f3b388fe7e | ||
|
|
af3e0a055d | ||
|
|
51d4032b86 | ||
|
|
0beb9ffea9 | ||
|
|
9e6a6b5fdc | ||
|
|
48fb953b93 | ||
|
|
4bde0495f7 | ||
|
|
a80caf7933 | ||
|
|
790790ab37 | ||
|
|
920e3c9834 | ||
|
|
13893c80cd | ||
|
|
a192a2215f | ||
|
|
e8fed6d25a | ||
|
|
d9b5708df0 | ||
|
|
68328f3d8e | ||
|
|
53e947935a | ||
|
|
324c26da78 | ||
|
|
389f98e349 | ||
|
|
ce9ef8d88f | ||
|
|
13bc3d3b4e | ||
|
|
3f5870281a | ||
|
|
a46df85479 | ||
|
|
c31581b1b9 | ||
|
|
26ae034153 | ||
|
|
baa2143a26 | ||
|
|
8b1910b5b7 | ||
|
|
d36bb94335 | ||
|
|
74034947c6 | ||
|
|
c7183fe7f1 | ||
|
|
139a352c80 | ||
|
|
336c94137f | ||
|
|
93225b6487 | ||
|
|
e11ea81008 | ||
|
|
bc8afeb139 | ||
|
|
f876345656 | ||
|
|
d487726f4d | ||
|
|
e6fc07da37 | ||
|
|
e6d589eb07 |
@@ -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
|
||||||
@@ -149,6 +149,8 @@ webui/.module-test-build/
|
|||||||
webui/.policy-test-build/
|
webui/.policy-test-build/
|
||||||
webui/.template-preview-test-build/
|
webui/.template-preview-test-build/
|
||||||
webui/.import-test-build/
|
webui/.import-test-build/
|
||||||
|
webui/dist-conformance/
|
||||||
|
webui/test-results/
|
||||||
|
|
||||||
# Security audit reports
|
# Security audit reports
|
||||||
audit-reports/
|
audit-reports/
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ tools/checks/check-focused.sh
|
|||||||
- Prefer `rg`, `sed`, and targeted tests over broad recursive scans or full builds.
|
- 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.
|
- 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.
|
- 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.
|
- 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 keep generated WebUI test folders in git status; they should be ignored and removable.
|
||||||
- Do not start persistent dev servers unless the user asks.
|
- Do not start persistent dev servers unless the user asks.
|
||||||
|
|||||||
@@ -4,13 +4,6 @@
|
|||||||
**Repository type:** system (kernel).
|
**Repository type:** system (kernel).
|
||||||
<!-- govoplan-repository-type:end -->
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
[](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=module-matrix.yml&actor=0&status=0)
|
|
||||||
[](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=release-integration.yml&actor=0&status=0)
|
|
||||||
[](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=dependency-audit.yml&actor=0&status=0)
|
|
||||||
[](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=security-audit.yml&actor=0&status=0)
|
|
||||||
|
|
||||||
# govoplan-core
|
|
||||||
|
|
||||||
GovOPlaN core is the platform runner and shared foundation. It owns the server entry point, database/session primitives, module discovery, migration orchestration, capability contracts, install/uninstall orchestration, and the shared WebUI shell. Platform and feature behavior is supplied by installed modules.
|
GovOPlaN core is the platform runner and shared foundation. It owns the server entry point, database/session primitives, module discovery, migration orchestration, capability contracts, install/uninstall orchestration, and the shared WebUI shell. Platform and feature behavior is supplied by installed modules.
|
||||||
|
|
||||||
## Repository ownership
|
## Repository ownership
|
||||||
@@ -23,6 +16,9 @@ Core owns:
|
|||||||
- kernel APIs for platform metadata, module lifecycle, health, and development diagnostics
|
- 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
|
- `@govoplan/core-webui`, including login, CSRF/API helpers, shell layout, generic UI components, IconRail, DataGrid, access boundaries, and module route/nav contracts
|
||||||
|
|
||||||
|
The shared DataGrid sizing and resize invariants are specified in
|
||||||
|
[`docs/DATAGRID_SIZING_CONTRACT.md`](docs/DATAGRID_SIZING_CONTRACT.md).
|
||||||
|
|
||||||
Platform and feature modules own their backend routers, models, migrations,
|
Platform and feature modules own their backend routers, models, migrations,
|
||||||
permissions, frontend packages, nav items, and route contributions. Access,
|
permissions, frontend packages, nav items, and route contributions. Access,
|
||||||
tenancy, policy, audit, and admin behavior live in their owning platform
|
tenancy, policy, audit, and admin behavior live in their owning platform
|
||||||
@@ -54,7 +50,7 @@ python3 -m venv .venv
|
|||||||
./.venv/bin/python -m pip install -r requirements-dev.txt
|
./.venv/bin/python -m pip install -r requirements-dev.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the platform server from core through the module-aware development runner. The default config reads `ENABLED_MODULES` and discovers installed module entry points. Local development defaults to `tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops`; set `ENABLED_MODULES` explicitly when testing a smaller module permutation.
|
Run the platform server from core through the module-aware development runner. The default config reads `ENABLED_MODULES` and discovers installed module entry points. Local development defaults to the modules listed by `govoplan_core.settings.Settings.enabled_modules`, including Views when its package is installed; set `ENABLED_MODULES` explicitly when testing a smaller module permutation.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan-core
|
cd /mnt/DATA/git/govoplan-core
|
||||||
@@ -74,6 +70,21 @@ ENABLED_MODULES=access,campaigns /mnt/DATA/git/govoplan/.venv/bin/python -m govo
|
|||||||
|
|
||||||
The runner loads the same `GovoplanServerConfig` as `govoplan_core.server.app:app`, builds the platform registry, and passes core plus enabled module source roots to uvicorn as reload directories. After reinstalling the editable package, the same command is also available as `govoplan-devserver`.
|
The runner loads the same `GovoplanServerConfig` as `govoplan_core.server.app:app`, builds the platform registry, and passes core plus enabled module source roots to uvicorn as reload directories. After reinstalling the editable package, the same command is also available as `govoplan-devserver`.
|
||||||
|
|
||||||
|
For focused backend work, keep the complete module graph active while watching
|
||||||
|
only the module being edited. Core/config sources and explicit `--reload-dir`
|
||||||
|
paths remain watched:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/mnt/DATA/git/govoplan/.venv/bin/python -m govoplan_core.devserver \
|
||||||
|
--reload-module calendar \
|
||||||
|
--reload-module campaign
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--reload-core-only` when no optional module source tree should trigger a
|
||||||
|
restart. Omitting both options preserves the broad default and watches every
|
||||||
|
enabled module. Startup, migration, and compatibility checks still run against
|
||||||
|
the complete enabled graph whenever the backend restarts.
|
||||||
|
|
||||||
The default development database is PostgreSQL at `postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev`. Store the password in `~/.pgpass`. To force the disposable SQLite fallback, run with `GOVOPLAN_DEV_DATABASE_BACKEND=sqlite`; that database lives below `runtime/`.
|
The default development database is PostgreSQL at `postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev`. Store the password in `~/.pgpass`. To force the disposable SQLite fallback, run with `GOVOPLAN_DEV_DATABASE_BACKEND=sqlite`; that database lives below `runtime/`.
|
||||||
|
|
||||||
Local devserver runs do not require Redis. `CELERY_ENABLED` defaults to `false`, so campaign queue actions update database state without publishing Celery tasks. Use the synchronous send flow for local send tests, or set `CELERY_ENABLED=true` only when a Redis broker and worker are running.
|
Local devserver runs do not require Redis. `CELERY_ENABLED` defaults to `false`, so campaign queue actions update database state without publishing Celery tasks. Use the synchronous send flow for local send tests, or set `CELERY_ENABLED=true` only when a Redis broker and worker are running.
|
||||||
@@ -106,7 +117,7 @@ CI runs the `ci` profile in report-only mode and uploads `audit-reports/` as an
|
|||||||
artifact. Once the baseline is clean, set `SECURITY_AUDIT_FAIL_ON_FINDINGS=1`
|
artifact. Once the baseline is clean, set `SECURITY_AUDIT_FAIL_ON_FINDINGS=1`
|
||||||
or pass `--strict` locally to turn findings into a failing gate.
|
or pass `--strict` locally to turn findings into a failing gate.
|
||||||
|
|
||||||
`govoplan_core.devserver` enables the development bootstrap before loading settings. In dev, startup migrations create or upgrade the schema and the bootstrap creates the default development login if needed. Explicitly setting `DEV_BOOTSTRAP_ENABLED=false` disables this convenience. Production deployments should use migrations and managed database provisioning instead.
|
`govoplan_core.devserver` enables the development bootstrap before loading settings. In dev, startup migrations create or upgrade the schema and the bootstrap creates the default development login if needed. Explicitly setting `DEV_BOOTSTRAP_ENABLED=false` disables this convenience. Production deployments use the separate, expiring single-use flow exposed by `python -m govoplan_core.commands.first_admin`; it cannot enable or consume development bootstrap credentials.
|
||||||
|
|
||||||
To verify the effective runtime paths and bootstrap behavior without starting uvicorn, run the smoke mode:
|
To verify the effective runtime paths and bootstrap behavior without starting uvicorn, run the smoke mode:
|
||||||
|
|
||||||
@@ -143,6 +154,10 @@ PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versio
|
|||||||
|
|
||||||
The local host links sibling module WebUI packages through local file dependencies and Vite filesystem allowances. Release builds should use `webui/package.release.json`, which points WebUI module packages at tagged git refs. See [RELEASE_DEPENDENCIES.md](docs/RELEASE_DEPENDENCIES.md).
|
The local host links sibling module WebUI packages through local file dependencies and Vite filesystem allowances. Release builds should use `webui/package.release.json`, which points WebUI module packages at tagged git refs. See [RELEASE_DEPENDENCIES.md](docs/RELEASE_DEPENDENCIES.md).
|
||||||
|
|
||||||
|
Production builds lazy-load enabled module descriptors and enforce initial and
|
||||||
|
asynchronous JavaScript budgets. See
|
||||||
|
[WEBUI_BUNDLE_BUDGETS.md](docs/WEBUI_BUNDLE_BUDGETS.md).
|
||||||
|
|
||||||
## Module contract
|
## Module contract
|
||||||
|
|
||||||
Backend modules register through the `govoplan.modules` entry point and return a `ModuleManifest`. A manifest can contribute:
|
Backend modules register through the `govoplan.modules` entry point and return a `ModuleManifest`. A manifest can contribute:
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib.util import module_from_spec, spec_from_file_location
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_path = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "versions"
|
||||||
|
/ "a36d8e4f9b12_german_reference_locale.py"
|
||||||
|
)
|
||||||
|
_spec = spec_from_file_location("govoplan_german_reference_locale_migration", _path)
|
||||||
|
if _spec is None or _spec.loader is None:
|
||||||
|
raise RuntimeError(f"Unable to load migration implementation from {_path}")
|
||||||
|
_module = module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(_module)
|
||||||
|
|
||||||
|
revision = _module.revision
|
||||||
|
down_revision = _module.down_revision
|
||||||
|
branch_labels = _module.branch_labels
|
||||||
|
depends_on = _module.depends_on
|
||||||
|
upgrade = _module.upgrade
|
||||||
|
downgrade = _module.downgrade
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib.util import module_from_spec, spec_from_file_location
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_path = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "versions"
|
||||||
|
/ "b47e6f809a13_data_subject_requests.py"
|
||||||
|
)
|
||||||
|
_spec = spec_from_file_location("govoplan_data_subject_requests_migration", _path)
|
||||||
|
if _spec is None or _spec.loader is None:
|
||||||
|
raise RuntimeError(f"Unable to load migration implementation from {_path}")
|
||||||
|
_module = module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(_module)
|
||||||
|
|
||||||
|
revision = _module.revision
|
||||||
|
down_revision = _module.down_revision
|
||||||
|
branch_labels = _module.branch_labels
|
||||||
|
depends_on = _module.depends_on
|
||||||
|
upgrade = _module.upgrade
|
||||||
|
downgrade = _module.downgrade
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""add reusable core credential envelopes
|
||||||
|
|
||||||
|
Revision ID: c91f0a72be34
|
||||||
|
Revises: 0f1e2d3c4b5a
|
||||||
|
Create Date: 2026-07-23 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "c91f0a72be34"
|
||||||
|
down_revision = "0f1e2d3c4b5a"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "core_credential_envelopes" in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
op.create_table(
|
||||||
|
"core_credential_envelopes",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("scope_type", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("scope_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("credential_kind", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("public_data", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("secret_data_encrypted", sa.Text(), nullable=True),
|
||||||
|
sa.Column("secret_keys", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("allowed_modules", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("allowed_server_refs", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("inherit_to_lower_scopes", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("revision", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["tenant_id"],
|
||||||
|
["core_scopes.id"],
|
||||||
|
name=op.f("fk_core_credential_envelopes_tenant_id_core_scopes"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_credential_envelopes")),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_credential_envelopes_scope",
|
||||||
|
"core_credential_envelopes",
|
||||||
|
["tenant_id", "scope_type", "scope_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_credential_envelopes_active",
|
||||||
|
"core_credential_envelopes",
|
||||||
|
["tenant_id", "is_active", "deleted_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"scope_type",
|
||||||
|
"scope_id",
|
||||||
|
"credential_kind",
|
||||||
|
"is_active",
|
||||||
|
"created_by_user_id",
|
||||||
|
"updated_by_user_id",
|
||||||
|
"deleted_at",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_core_credential_envelopes_{column}"),
|
||||||
|
"core_credential_envelopes",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "core_credential_envelopes" in inspector.get_table_names():
|
||||||
|
op.drop_table("core_credential_envelopes")
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""development-track wrapper for generic ownership transfers."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib.util import module_from_spec, spec_from_file_location
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_path = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "versions"
|
||||||
|
/ "d03a7b9c1e5f_core_ownership_transfers.py"
|
||||||
|
)
|
||||||
|
_spec = spec_from_file_location("govoplan_core_ownership_transfer_migration", _path)
|
||||||
|
if _spec is None or _spec.loader is None:
|
||||||
|
raise RuntimeError(f"Unable to load ownership migration from {_path}")
|
||||||
|
_migration = module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(_migration)
|
||||||
|
|
||||||
|
revision = _migration.revision
|
||||||
|
down_revision = _migration.down_revision
|
||||||
|
branch_labels = _migration.branch_labels
|
||||||
|
depends_on = _migration.depends_on
|
||||||
|
upgrade = _migration.upgrade
|
||||||
|
downgrade = _migration.downgrade
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""development-track wrapper for runtime coordination and recovery."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib.util import module_from_spec, spec_from_file_location
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_path = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "versions"
|
||||||
|
/ "e14b8c2d6f90_runtime_coordination_recovery.py"
|
||||||
|
)
|
||||||
|
_spec = spec_from_file_location("govoplan_runtime_coordination_migration", _path)
|
||||||
|
if _spec is None or _spec.loader is None:
|
||||||
|
raise RuntimeError(f"Unable to load runtime coordination migration from {_path}")
|
||||||
|
_migration = module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(_migration)
|
||||||
|
|
||||||
|
revision = _migration.revision
|
||||||
|
down_revision = _migration.down_revision
|
||||||
|
branch_labels = _migration.branch_labels
|
||||||
|
depends_on = _migration.depends_on
|
||||||
|
upgrade = _migration.upgrade
|
||||||
|
downgrade = _migration.downgrade
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib.util import module_from_spec, spec_from_file_location
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_path = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "versions"
|
||||||
|
/ "f25c9d3e7a01_first_admin_enrollment.py"
|
||||||
|
)
|
||||||
|
_spec = spec_from_file_location("govoplan_first_admin_enrollment_migration", _path)
|
||||||
|
if _spec is None or _spec.loader is None:
|
||||||
|
raise RuntimeError(f"Unable to load migration implementation from {_path}")
|
||||||
|
_module = module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(_module)
|
||||||
|
|
||||||
|
revision = _module.revision
|
||||||
|
down_revision = _module.down_revision
|
||||||
|
branch_labels = _module.branch_labels
|
||||||
|
depends_on = _module.depends_on
|
||||||
|
upgrade = _module.upgrade
|
||||||
|
downgrade = _module.downgrade
|
||||||
+10
-1
@@ -5,9 +5,18 @@ from logging.config import fileConfig
|
|||||||
from alembic import context
|
from alembic import context
|
||||||
from sqlalchemy import engine_from_config, pool
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
|
try:
|
||||||
|
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate optional access metadata
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
if exc.name != "govoplan_access":
|
||||||
|
raise
|
||||||
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
|
from govoplan_core.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 change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
|
||||||
|
from govoplan_core.core import first_admin as core_first_admin_models # noqa: F401 - populate core metadata
|
||||||
|
from govoplan_core.core import ownership as core_ownership_models # noqa: F401 - populate core metadata
|
||||||
|
from govoplan_core.core import recovery as core_recovery_models # noqa: F401 - populate core metadata
|
||||||
|
from govoplan_core.core import runtime_coordination as core_runtime_models # noqa: F401 - populate core metadata
|
||||||
|
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
|
||||||
from govoplan_core.core.migrations import migration_metadata_plan
|
from govoplan_core.core.migrations import migration_metadata_plan
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_core.server.default_config import get_server_config
|
from govoplan_core.server.default_config import get_server_config
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""adopt German as the untouched system reference locale
|
||||||
|
|
||||||
|
Revision ID: a36d8e4f9b12
|
||||||
|
Revises: f25c9d3e7a01
|
||||||
|
Create Date: 2026-08-05 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "a36d8e4f9b12"
|
||||||
|
down_revision = "f25c9d3e7a01"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if "core_system_settings" not in set(sa.inspect(bind).get_table_names()):
|
||||||
|
return
|
||||||
|
|
||||||
|
settings = sa.table(
|
||||||
|
"core_system_settings",
|
||||||
|
sa.column("id", sa.String),
|
||||||
|
sa.column("default_locale", sa.String),
|
||||||
|
sa.column("created_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.column("updated_at", sa.DateTime(timezone=True)),
|
||||||
|
)
|
||||||
|
bind.execute(
|
||||||
|
settings.update()
|
||||||
|
.where(settings.c.id == "global")
|
||||||
|
.where(settings.c.default_locale == "en")
|
||||||
|
.where(settings.c.created_at == settings.c.updated_at)
|
||||||
|
.values(default_locale="de", updated_at=sa.func.now())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Locale selection is user-visible state. A downgrade must not overwrite a
|
||||||
|
# German value that may have been selected explicitly after this migration.
|
||||||
|
pass
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""add governed data-subject request workflow
|
||||||
|
|
||||||
|
Revision ID: b47e6f809a13
|
||||||
|
Revises: a36d8e4f9b12
|
||||||
|
Create Date: 2026-08-07 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "b47e6f809a13"
|
||||||
|
down_revision = "a36d8e4f9b12"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "core_data_subject_requests" in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
op.create_table(
|
||||||
|
"core_data_subject_requests",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("reference", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("request_kind", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("subject", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("purpose", sa.String(length=1000), nullable=False),
|
||||||
|
sa.Column("legal_basis", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("requested_by_account_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("search_result", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("erasure_plan", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("execution_result", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("coverage", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("evidence_sha256", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("resource_revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_data_subject_requests")),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_data_subject_requests_tenant_id"),
|
||||||
|
"core_data_subject_requests",
|
||||||
|
["tenant_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_data_subject_requests_status"),
|
||||||
|
"core_data_subject_requests",
|
||||||
|
["status"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_data_subject_requests_due_at"),
|
||||||
|
"core_data_subject_requests",
|
||||||
|
["due_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_data_subject_requests_tenant_status",
|
||||||
|
"core_data_subject_requests",
|
||||||
|
["tenant_id", "status"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "core_data_subject_requests" in inspector.get_table_names():
|
||||||
|
op.drop_table("core_data_subject_requests")
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""add reusable core credential envelopes
|
||||||
|
|
||||||
|
Revision ID: c91f0a72be34
|
||||||
|
Revises: 4f2a9c8e7b6d
|
||||||
|
Create Date: 2026-07-23 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "c91f0a72be34"
|
||||||
|
down_revision = "4f2a9c8e7b6d"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "core_credential_envelopes" in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
op.create_table(
|
||||||
|
"core_credential_envelopes",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("scope_type", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("scope_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("credential_kind", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("public_data", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("secret_data_encrypted", sa.Text(), nullable=True),
|
||||||
|
sa.Column("secret_keys", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("allowed_modules", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("allowed_server_refs", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("inherit_to_lower_scopes", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("revision", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["tenant_id"],
|
||||||
|
["core_scopes.id"],
|
||||||
|
name=op.f("fk_core_credential_envelopes_tenant_id_core_scopes"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_credential_envelopes")),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_credential_envelopes_scope",
|
||||||
|
"core_credential_envelopes",
|
||||||
|
["tenant_id", "scope_type", "scope_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_credential_envelopes_active",
|
||||||
|
"core_credential_envelopes",
|
||||||
|
["tenant_id", "is_active", "deleted_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_credential_envelopes_tenant_id"),
|
||||||
|
"core_credential_envelopes",
|
||||||
|
["tenant_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_credential_envelopes_scope_type"),
|
||||||
|
"core_credential_envelopes",
|
||||||
|
["scope_type"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_credential_envelopes_scope_id"),
|
||||||
|
"core_credential_envelopes",
|
||||||
|
["scope_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_credential_envelopes_credential_kind"),
|
||||||
|
"core_credential_envelopes",
|
||||||
|
["credential_kind"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_credential_envelopes_is_active"),
|
||||||
|
"core_credential_envelopes",
|
||||||
|
["is_active"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_credential_envelopes_created_by_user_id"),
|
||||||
|
"core_credential_envelopes",
|
||||||
|
["created_by_user_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_credential_envelopes_updated_by_user_id"),
|
||||||
|
"core_credential_envelopes",
|
||||||
|
["updated_by_user_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_credential_envelopes_deleted_at"),
|
||||||
|
"core_credential_envelopes",
|
||||||
|
["deleted_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "core_credential_envelopes" in inspector.get_table_names():
|
||||||
|
op.drop_table("core_credential_envelopes")
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""add generic resource ownership transfer state
|
||||||
|
|
||||||
|
Revision ID: d03a7b9c1e5f
|
||||||
|
Revises: c91f0a72be34
|
||||||
|
Create Date: 2026-07-30 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "d03a7b9c1e5f"
|
||||||
|
down_revision = "c91f0a72be34"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "core_ownership_transfers" in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
op.create_table(
|
||||||
|
"core_ownership_transfers",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("resource_module", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("resource_type", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("resource_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("kind", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("current_owner_type", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("current_owner_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("target_owner_type", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("target_owner_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("initiated_by_type", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("initiated_by_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("owner_approved_by_type", sa.String(length=40), nullable=True),
|
||||||
|
sa.Column("owner_approved_by_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("target_accepted_by_type", sa.String(length=40), nullable=True),
|
||||||
|
sa.Column("target_accepted_by_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("reason", sa.Text(), nullable=True),
|
||||||
|
sa.Column("assurance_profile", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("required_approvals", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("approvals", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("decisions", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("canonical_request_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("execute_after", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("declined_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("expired_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_ownership_transfers")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"resource_module",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_core_ownership_transfer_idempotency",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_ownership_transfers_tenant_id"),
|
||||||
|
"core_ownership_transfers",
|
||||||
|
["tenant_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_ownership_transfers_kind"),
|
||||||
|
"core_ownership_transfers",
|
||||||
|
["kind"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_ownership_transfers_status"),
|
||||||
|
"core_ownership_transfers",
|
||||||
|
["status"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_ownership_transfer_resource",
|
||||||
|
"core_ownership_transfers",
|
||||||
|
[
|
||||||
|
"tenant_id",
|
||||||
|
"resource_module",
|
||||||
|
"resource_type",
|
||||||
|
"resource_id",
|
||||||
|
"status",
|
||||||
|
],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_ownership_transfer_expiry",
|
||||||
|
"core_ownership_transfers",
|
||||||
|
["status", "expires_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "core_ownership_transfers" not in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
op.drop_index(
|
||||||
|
"ix_core_ownership_transfer_expiry",
|
||||||
|
table_name="core_ownership_transfers",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"ix_core_ownership_transfer_resource",
|
||||||
|
table_name="core_ownership_transfers",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_core_ownership_transfers_status"),
|
||||||
|
table_name="core_ownership_transfers",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_core_ownership_transfers_kind"),
|
||||||
|
table_name="core_ownership_transfers",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_core_ownership_transfers_tenant_id"),
|
||||||
|
table_name="core_ownership_transfers",
|
||||||
|
)
|
||||||
|
op.drop_table("core_ownership_transfers")
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"""add runtime coordination and recovery evidence
|
||||||
|
|
||||||
|
Revision ID: e14b8c2d6f90
|
||||||
|
Revises: d03a7b9c1e5f
|
||||||
|
Create Date: 2026-08-01 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "e14b8c2d6f90"
|
||||||
|
down_revision = "d03a7b9c1e5f"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
if "core_runtime_nodes" not in tables:
|
||||||
|
op.create_table(
|
||||||
|
"core_runtime_nodes",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("installation_id", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("node_id", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("incarnation", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("role", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("software_version", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("composition_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("queues", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("state", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("last_heartbeat_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("drain_requested_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("drain_reason", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("stopped_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_runtime_nodes")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"installation_id",
|
||||||
|
"node_id",
|
||||||
|
name="uq_core_runtime_node_installation_node",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"installation_id",
|
||||||
|
"node_id",
|
||||||
|
"incarnation",
|
||||||
|
"role",
|
||||||
|
"composition_hash",
|
||||||
|
"state",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_core_runtime_nodes_{column}"),
|
||||||
|
"core_runtime_nodes",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_runtime_nodes_installation_state_heartbeat",
|
||||||
|
"core_runtime_nodes",
|
||||||
|
["installation_id", "state", "last_heartbeat_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if "core_distributed_leases" not in tables:
|
||||||
|
op.create_table(
|
||||||
|
"core_distributed_leases",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("installation_id", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("resource_key", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("holder_node_id", sa.String(length=200), nullable=True),
|
||||||
|
sa.Column("holder_incarnation", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("fencing_token", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("acquired_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("renewed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_distributed_leases")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"installation_id",
|
||||||
|
"resource_key",
|
||||||
|
name="uq_core_distributed_lease_resource",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"installation_id",
|
||||||
|
"resource_key",
|
||||||
|
"holder_node_id",
|
||||||
|
"holder_incarnation",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_core_distributed_leases_{column}"),
|
||||||
|
"core_distributed_leases",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_distributed_leases_expiry",
|
||||||
|
"core_distributed_leases",
|
||||||
|
["installation_id", "expires_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if "core_recovery_operations" not in tables:
|
||||||
|
op.create_table(
|
||||||
|
"core_recovery_operations",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("installation_id", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("module_id", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("operation_type", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("resource_type", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("resource_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("mode", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("plan", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("backup_reference", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("approval_reference", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("lease_resource_key", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("holder_node_id", sa.String(length=200), nullable=True),
|
||||||
|
sa.Column("holder_incarnation", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("fencing_token", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("checkpoint_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("evidence_head_sha256", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("failure_summary", sa.Text(), nullable=True),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("recovery_started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("recovered_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_recovery_operations")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"installation_id",
|
||||||
|
"module_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_core_recovery_operation_idempotency",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"installation_id",
|
||||||
|
"module_id",
|
||||||
|
"operation_type",
|
||||||
|
"resource_type",
|
||||||
|
"resource_id",
|
||||||
|
"mode",
|
||||||
|
"status",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_core_recovery_operations_{column}"),
|
||||||
|
"core_recovery_operations",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_recovery_operations_status_updated",
|
||||||
|
"core_recovery_operations",
|
||||||
|
["installation_id", "status", "updated_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_recovery_operations_resource",
|
||||||
|
"core_recovery_operations",
|
||||||
|
["module_id", "resource_type", "resource_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if "core_recovery_checkpoints" not in tables:
|
||||||
|
op.create_table(
|
||||||
|
"core_recovery_checkpoints",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("operation_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("kind", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("summary", sa.Text(), nullable=False),
|
||||||
|
sa.Column("evidence", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("previous_sha256", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("checkpoint_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["operation_id"],
|
||||||
|
["core_recovery_operations.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_core_recovery_checkpoints_operation_id_core_recovery_operations"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_recovery_checkpoints")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"operation_id",
|
||||||
|
"sequence",
|
||||||
|
name="uq_core_recovery_checkpoint_sequence",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in ("operation_id", "status", "checkpoint_sha256"):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_core_recovery_checkpoints_{column}"),
|
||||||
|
"core_recovery_checkpoints",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_recovery_checkpoints_operation_created",
|
||||||
|
"core_recovery_checkpoints",
|
||||||
|
["operation_id", "created_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
for table in (
|
||||||
|
"core_recovery_checkpoints",
|
||||||
|
"core_recovery_operations",
|
||||||
|
"core_distributed_leases",
|
||||||
|
"core_runtime_nodes",
|
||||||
|
):
|
||||||
|
if table in tables:
|
||||||
|
op.drop_table(table)
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""add controlled first-administrator enrollment evidence
|
||||||
|
|
||||||
|
Revision ID: f25c9d3e7a01
|
||||||
|
Revises: e14b8c2d6f90
|
||||||
|
Create Date: 2026-08-04 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "f25c9d3e7a01"
|
||||||
|
down_revision = "e14b8c2d6f90"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
if "core_first_admin_enrollments" not in tables:
|
||||||
|
op.create_table(
|
||||||
|
"core_first_admin_enrollments",
|
||||||
|
sa.Column("installation_id", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("state", sa.String(length=24), nullable=False),
|
||||||
|
sa.Column("generation", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("token_sha256", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("token_fingerprint", sa.String(length=16), nullable=True),
|
||||||
|
sa.Column("issued_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("consumed_account_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("consumed_membership_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("consumed_tenant_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("consumed_email", sa.String(length=320), nullable=True),
|
||||||
|
sa.Column("consumed_display_name", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("consumed_request_sha256", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("issue_reason", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("event_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("evidence_head_sha256", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"installation_id",
|
||||||
|
name=op.f("pk_core_first_admin_enrollments"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_first_admin_enrollments_state"),
|
||||||
|
"core_first_admin_enrollments",
|
||||||
|
["state"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_first_admin_enrollments_expires_at"),
|
||||||
|
"core_first_admin_enrollments",
|
||||||
|
["expires_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
if "core_first_admin_enrollment_events" not in tables:
|
||||||
|
op.create_table(
|
||||||
|
"core_first_admin_enrollment_events",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("installation_id", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("event_type", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("generation", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("evidence", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("previous_sha256", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("event_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["installation_id"],
|
||||||
|
["core_first_admin_enrollments.installation_id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_core_first_admin_enrollment_events_installation_id_core_first_admin_enrollments"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"id",
|
||||||
|
name=op.f("pk_core_first_admin_enrollment_events"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"installation_id",
|
||||||
|
"sequence",
|
||||||
|
name="uq_core_first_admin_enrollment_event_sequence",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in ("installation_id", "event_type", "event_sha256"):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_core_first_admin_enrollment_events_{column}"),
|
||||||
|
"core_first_admin_enrollment_events",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
if "core_first_admin_enrollment_events" in tables:
|
||||||
|
op.drop_table("core_first_admin_enrollment_events")
|
||||||
|
if "core_first_admin_enrollments" in tables:
|
||||||
|
op.drop_table("core_first_admin_enrollments")
|
||||||
@@ -5,7 +5,7 @@ only linear screen flows. Workflows, schedules, imports, connectors, policies,
|
|||||||
and external events all need to request governed actions without bypassing the
|
and external events all need to request governed actions without bypassing the
|
||||||
same safety rules that apply to human users.
|
same safety rules that apply to human users.
|
||||||
|
|
||||||
The first implementation should live in `govoplan-workflow` and core contracts.
|
The first implementation lives in `govoplan-workflow-engine` and Core contracts.
|
||||||
Create a separate `govoplan-automation` module only if action planning,
|
Create a separate `govoplan-automation` module only if action planning,
|
||||||
schedulers, rule execution, or cross-module automation become too broad for
|
schedulers, rule execution, or cross-module automation become too broad for
|
||||||
workflow ownership.
|
workflow ownership.
|
||||||
@@ -29,6 +29,10 @@ of module capabilities.
|
|||||||
## Action Definition
|
## Action Definition
|
||||||
|
|
||||||
An `ActionDefinition` describes something a human or system actor can request.
|
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:
|
Recommended fields:
|
||||||
|
|
||||||
@@ -43,6 +47,9 @@ Recommended fields:
|
|||||||
irreversible
|
irreversible
|
||||||
- expected effects
|
- expected effects
|
||||||
- idempotency key strategy
|
- 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
|
- audit event names
|
||||||
- preview provider
|
- preview provider
|
||||||
|
|
||||||
@@ -85,16 +92,45 @@ The runner should execute an action plan as follows:
|
|||||||
4. Run permission and policy checks.
|
4. Run permission and policy checks.
|
||||||
5. Generate a consequence preview.
|
5. Generate a consequence preview.
|
||||||
6. Reserve or verify the idempotency key.
|
6. Reserve or verify the idempotency key.
|
||||||
7. Execute the owning module capability.
|
7. Create a durable recovery operation and acquire its execution fence.
|
||||||
8. Record observed effects.
|
8. Persist dispatch evidence before a non-atomic provider call.
|
||||||
9. Emit events and audit records.
|
9. Execute the owning module capability.
|
||||||
10. Mark the command complete, retryable, quarantined, or requiring manual
|
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.
|
intervention.
|
||||||
|
|
||||||
The runner must never advance workflow state past a required side effect unless
|
The runner must never advance workflow state past a required side effect unless
|
||||||
the action definition explicitly allows asynchronous completion and the pending
|
the action definition explicitly allows asynchronous completion and the pending
|
||||||
state is visible.
|
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
|
## Failure States
|
||||||
|
|
||||||
Automation should use explicit failure states:
|
Automation should use explicit failure states:
|
||||||
@@ -110,10 +146,15 @@ Automation should use explicit failure states:
|
|||||||
|
|
||||||
These states should be visible in workflow, task, and admin diagnostics.
|
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
|
## Boundary
|
||||||
|
|
||||||
Core may own stable DTOs, registry contracts, and generic audit/event hooks.
|
Core may own stable DTOs, registry contracts, and generic audit/event hooks.
|
||||||
`govoplan-workflow` should own the first runner because workflow is the first
|
`govoplan-workflow-engine` owns the first runner because workflow is the first
|
||||||
module that coordinates cross-module process actions.
|
module that coordinates cross-module process actions.
|
||||||
|
|
||||||
Domain modules own their own action providers. For example, templates own
|
Domain modules own their own action providers. For example, templates own
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -49,6 +49,15 @@ The broad writable root reduces approval churn. The explicit project trust entri
|
|||||||
|
|
||||||
Each active repository has an `AGENTS.md` file. These files define ownership, module boundaries, and focused commands for Codex. Keep durable project conventions there instead of repeating them in every prompt.
|
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 `~/.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.
|
Use Gitea issues as the canonical backlog and state log. See `docs/GITEA_ISSUES.md` for label setup, TODO import, and Codex issue update commands. Durable docs should describe stable behavior; changing work state belongs on the issue.
|
||||||
@@ -81,5 +90,6 @@ PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versi
|
|||||||
- Avoid broad recursive scans and full builds unless the change warrants them.
|
- Avoid broad recursive scans and full builds unless the change warrants them.
|
||||||
- Keep generated build/test folders ignored.
|
- Keep generated build/test folders ignored.
|
||||||
- Keep optional module behavior behind core registry/capability/module metadata boundaries.
|
- 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.
|
- 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.
|
- 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.
|
||||||
@@ -48,6 +48,35 @@ interface = how configured parts connect
|
|||||||
data = what the operator must provide for this deployment
|
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
|
## Package Model
|
||||||
|
|
||||||
A configuration package should be a signed, portable manifest plus module-owned
|
A configuration package should be a signed, portable manifest plus module-owned
|
||||||
@@ -68,6 +97,14 @@ Required package metadata:
|
|||||||
- preflight checks and post-import health checks
|
- preflight checks and post-import health checks
|
||||||
- migration or transformation rules for older package versions
|
- migration or transformation rules for older package versions
|
||||||
- provenance, export source metadata, and signature metadata
|
- 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
|
Configuration fragments are interpreted only by the module that owns them. For
|
||||||
example, workflow imports workflow definitions; forms imports form schemas;
|
example, workflow imports workflow definitions; forms imports form schemas;
|
||||||
@@ -122,7 +159,36 @@ The initial implementation includes provider-neutral orchestration helpers:
|
|||||||
|
|
||||||
The first concrete provider is `govoplan_access.backend.configuration_provider`.
|
The first concrete provider is `govoplan_access.backend.configuration_provider`.
|
||||||
It supports access-owned `roles`, `groups`, and `group_role_assignments`
|
It supports access-owned `roles`, `groups`, and `group_role_assignments`
|
||||||
fragments and applies them idempotently.
|
fragments and applies them idempotently. Mail and Files also register providers
|
||||||
|
for deployment configuration: Mail owns receipt-bound SMTP profiles and Files
|
||||||
|
validates the deployment-owned managed-storage binding.
|
||||||
|
|
||||||
|
### Deployment capability receipt
|
||||||
|
|
||||||
|
The installer mounts a bounded, non-secret infrastructure receipt at the path
|
||||||
|
named by `GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH`. Core validates that document
|
||||||
|
once for configuration-package context and exposes typed capability and
|
||||||
|
post-install-task records to providers. Invalid receipts fail closed. Endpoint
|
||||||
|
metadata is sanitized, and secret fields may cross this boundary only as
|
||||||
|
`env:VARIABLE_NAME` references.
|
||||||
|
|
||||||
|
Feature providers remain responsible for their own semantics:
|
||||||
|
|
||||||
|
- Mail can derive host and port from `mail.smtp`, collect missing non-secret
|
||||||
|
transport fields, and bind an existing credential-envelope id. It never
|
||||||
|
accepts or exports a username, password, token, or decrypted credential.
|
||||||
|
- Files compares `files.storage` with the effective runtime backend, endpoint,
|
||||||
|
trust marker, bucket, and presence of referenced environment secrets. Storage
|
||||||
|
remains deployment-owned, so the provider reports `skip` when they agree and
|
||||||
|
blocks drift instead of rewriting process environment or storage credentials.
|
||||||
|
- A system-scoped Mail profile requires system configuration authority. Tenant
|
||||||
|
scope is the conservative default.
|
||||||
|
- Existing Mail configuration is preserved unless a reviewed fragment
|
||||||
|
explicitly selects `on_conflict: update`. Reapplying an unchanged fragment is
|
||||||
|
a no-op.
|
||||||
|
|
||||||
|
Ops projects the same Core-validated receipt. It must not maintain a second
|
||||||
|
parser with different validation or secret-handling rules.
|
||||||
|
|
||||||
The admin wizard backend starts with these routes:
|
The admin wizard backend starts with these routes:
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -7,6 +7,16 @@ files.
|
|||||||
|
|
||||||
## Runtime Configuration Contract
|
## 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 follows the staged approach documented in
|
||||||
`SELF_HOSTED_INSTALLABILITY.md`: generate an explicit env template, validate it,
|
`SELF_HOSTED_INSTALLABILITY.md`: generate an explicit env template, validate it,
|
||||||
run production-like rehearsal with Compose-backed dependencies, then use the
|
run production-like rehearsal with Compose-backed dependencies, then use the
|
||||||
@@ -36,7 +46,7 @@ set +a
|
|||||||
| Setting | Required outside dev | Purpose |
|
| Setting | Required outside dev | Purpose |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `APP_ENV` | yes | Runtime profile. Use `prod`, `staging`, or a deployment-specific value outside local development. |
|
| `APP_ENV` | yes | Runtime profile. Use `prod`, `staging`, or a deployment-specific value outside local development. |
|
||||||
| `MASTER_KEY_B64` | yes | Fernet key or base64 encoded 32-byte key used for encrypted module secrets. Rotate through an explicit operator plan. |
|
| `MASTER_KEY_B64` | yes | Fernet key or base64 encoded 32-byte deployment root used for encrypted module secrets and, when enabled, the Encryption module's local server-envelope provider. Rotate only through an explicit provider-aware migration plan. |
|
||||||
| `DATABASE_URL` | yes | SQLAlchemy database URL for core and installed modules. SQLite is supported for dev/small installs; PostgreSQL is the preferred production target. |
|
| `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. |
|
| `ENABLED_MODULES` | yes | Comma-separated startup module set. Keep `tenancy,access` enabled; keep `admin` enabled for operator UI. |
|
||||||
|
|
||||||
@@ -57,6 +67,8 @@ PY
|
|||||||
| `GOVOPLAN_MIGRATION_TRACK` | `release` | Use the release track for normal runtime and deployments. Use `dev` only for fresh/disposable databases that intentionally replay detailed development migrations. |
|
| `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_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. |
|
| `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
|
Operator rule: take a database backup before applying migrations or destructive
|
||||||
module retirement. For non-SQLite databases, configure deployment-specific
|
module retirement. For non-SQLite databases, configure deployment-specific
|
||||||
@@ -144,8 +156,12 @@ tools/checks/postgres-integration-check.py \
|
|||||||
```
|
```
|
||||||
|
|
||||||
The integration check runs migrations and startup smoke checks across the
|
The integration check runs migrations and startup smoke checks across the
|
||||||
standard module permutations. `--reset-schema` is destructive and belongs only
|
standard module permutations. It first requires the retirement atomicity proof,
|
||||||
on throwaway databases.
|
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
|
### Broker And Workers
|
||||||
|
|
||||||
@@ -153,13 +169,16 @@ on throwaway databases.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. |
|
| `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_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. |
|
||||||
| `CELERY_QUEUES` | `send_email,append_sent,notifications,calendar,default` | Queue list expected by worker/process manager definitions. The Calendar queue drains durable external-calendar operations. |
|
| `CELERY_QUEUES` | `send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default` | Queue list expected by worker/process manager definitions. Keep this aligned with every enabled module task route; Ops reports missing worker queue consumers. |
|
||||||
|
| `CELERY_VISIBILITY_TIMEOUT_SECONDS` | `3600` | Maximum time before Redis may redeliver work left unacknowledged by a lost worker. Set this above the longest supported task duration; changing it requires a worker-loss acceptance drill. |
|
||||||
|
| `PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS` | `8` | Failed durable consumer deliveries are quarantined after this many attempts. |
|
||||||
|
| `PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS` | `90` | Successful event envelopes older than this are removed by the daily retention task. Quarantined evidence is retained. |
|
||||||
|
|
||||||
Worker command:
|
Worker command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m celery -A govoplan_core.celery_app:celery worker \
|
python -m celery -A govoplan_core.celery_app:celery worker \
|
||||||
--queues send_email,append_sent,notifications,calendar,default \
|
--queues send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default \
|
||||||
--loglevel INFO
|
--loglevel INFO
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -171,6 +190,13 @@ crashes, and expired worker leases:
|
|||||||
python -m celery -A govoplan_core.celery_app:celery beat --loglevel INFO
|
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
|
### Storage
|
||||||
|
|
||||||
| Setting | Default | Notes |
|
| Setting | Default | Notes |
|
||||||
@@ -183,6 +209,11 @@ python -m celery -A govoplan_core.celery_app:celery beat --loglevel INFO
|
|||||||
| `FILE_STORAGE_S3_ACCESS_KEY_ID` | empty | Secret; inject through deployment environment. |
|
| `FILE_STORAGE_S3_ACCESS_KEY_ID` | empty | Secret; inject through deployment environment. |
|
||||||
| `FILE_STORAGE_S3_SECRET_ACCESS_KEY` | 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_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
|
Legacy `S3_*` settings remain for older storage paths but new deployments should
|
||||||
prefer `FILE_STORAGE_*`.
|
prefer `FILE_STORAGE_*`.
|
||||||
@@ -205,9 +236,13 @@ prefer `FILE_STORAGE_*`.
|
|||||||
Interactive password login is enabled with fixed-window limits of 10 failures
|
Interactive password login is enabled with fixed-window limits of 10 failures
|
||||||
per normalized identity and 100 failures per direct client over 900 seconds.
|
per normalized identity and 100 failures per direct client over 900 seconds.
|
||||||
`AUTH_LOGIN_THROTTLE_*` settings change those limits. Counters use `REDIS_URL`
|
`AUTH_LOGIN_THROTTLE_*` settings change those limits. Counters use `REDIS_URL`
|
||||||
when Redis is reachable so replicas share state; a bounded process-local
|
when Redis is reachable so replicas share state. Production-like startup fails
|
||||||
fallback keeps development and Redis outages functional, with per-process
|
when throttling is enabled without `REDIS_URL`. Set
|
||||||
enforcement until Redis recovers.
|
`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
|
### Outbound Connector Egress
|
||||||
|
|
||||||
@@ -253,12 +288,15 @@ through the same trusted address range.
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `GOVOPLAN_MODULE_PACKAGE_CATALOG_URL` or `GOVOPLAN_MODULE_PACKAGE_CATALOG` | Module package catalog source. |
|
| `GOVOPLAN_MODULE_PACKAGE_CATALOG_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_TRUSTED_KEYS_FILE` | Preferred production keyring path. |
|
||||||
| `GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL` | Approved catalog channel, for example `stable`. |
|
| `GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS` | Comma-separated approved catalog channels, for example `stable`. The legacy singular name remains readable during migration. |
|
||||||
| `GOVOPLAN_LICENSE_TRUSTED_KEYS_FILE` | Trusted license issuer keyring path. |
|
| `GOVOPLAN_LICENSE_TRUSTED_KEYS_FILE` | Trusted license issuer keyring path. |
|
||||||
| `GOVOPLAN_LICENSE_ENFORCEMENT` | Enables license enforcement when set to `true`. |
|
| `GOVOPLAN_LICENSE_ENFORCEMENT` | Enables license enforcement when set to `true`. |
|
||||||
|
|
||||||
Trust roots are deployment-managed and should not be editable through the
|
Trust roots are deployment-managed and should not be editable through the
|
||||||
running WebUI.
|
running WebUI. When no catalog override is configured, the Admin package
|
||||||
|
directory uses GovOPlaN's public stable catalog and the trust anchor bundled
|
||||||
|
with the installed Core release. Production operators may still pin a newer or
|
||||||
|
institution-specific catalog/keyring explicitly with the settings above.
|
||||||
|
|
||||||
### Mail Test Credentials
|
### Mail Test Credentials
|
||||||
|
|
||||||
@@ -277,8 +315,34 @@ configuration, not the core runtime contract. Store them in a local ignored
|
|||||||
3. Build the WebUI from `webui/package.release.json` or deploy a prebuilt
|
3. Build the WebUI from `webui/package.release.json` or deploy a prebuilt
|
||||||
artifact from the same release tag.
|
artifact from the same release tag.
|
||||||
4. Run database migrations with the target `DATABASE_URL`.
|
4. Run database migrations with the target `DATABASE_URL`.
|
||||||
5. Create the first tenant and system owner through the controlled bootstrap or
|
5. Create the first tenant and system owner through the controlled bootstrap:
|
||||||
one-time admin command for the deployment.
|
|
||||||
|
```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`.
|
6. Start the API service with `govoplan_core.server.app:app`.
|
||||||
7. Start workers when `CELERY_ENABLED=true`.
|
7. Start workers when `CELERY_ENABLED=true`.
|
||||||
8. Start the WebUI/reverse proxy and verify CORS/cookie settings.
|
8. Start the WebUI/reverse proxy and verify CORS/cookie settings.
|
||||||
@@ -328,7 +392,7 @@ the checked in `.env.example`. It runs:
|
|||||||
- explicit `ENABLED_MODULES`
|
- explicit `ENABLED_MODULES`
|
||||||
- explicit migrations and `--with-dev-data` bootstrap
|
- explicit migrations and `--with-dev-data` bootstrap
|
||||||
- API via the module-aware devserver
|
- API via the module-aware devserver
|
||||||
- a Celery worker for `send_email,append_sent,notifications,calendar,default`
|
- a Celery worker for `send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default`
|
||||||
- WebUI through the Vite dev server
|
- WebUI through the Vite dev server
|
||||||
- durable local files under `runtime/production-like/files`
|
- durable local files under `runtime/production-like/files`
|
||||||
|
|
||||||
@@ -408,6 +472,14 @@ SQLite's backup API; non-SQLite databases require
|
|||||||
`--database-backup-command`, `--database-restore-check-command`, and
|
`--database-backup-command`, `--database-restore-check-command`, and
|
||||||
`--database-restore-command`.
|
`--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:
|
Database hook commands receive:
|
||||||
|
|
||||||
- `GOVOPLAN_INSTALLER_RUN_DIR`
|
- `GOVOPLAN_INSTALLER_RUN_DIR`
|
||||||
@@ -447,7 +519,9 @@ Run the rollback drill before relying on installer automation in a new
|
|||||||
environment:
|
environment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/mnt/DATA/git/govoplan/tools/checks/module-installer-rollback-drill.py --format json
|
/mnt/DATA/git/govoplan/tools/checks/module-installer-rollback-drill.py \
|
||||||
|
--format json \
|
||||||
|
--evidence-path runtime/module-installer/restore-drill-evidence.json
|
||||||
```
|
```
|
||||||
|
|
||||||
The drill uses temporary SQLite databases and simulated package commands. It
|
The drill uses temporary SQLite databases and simulated package commands. It
|
||||||
@@ -468,7 +542,7 @@ checks, catalog trust, signing, keyring, replay, and license operation.
|
|||||||
## Operator Checklist
|
## Operator Checklist
|
||||||
|
|
||||||
- Runtime secrets are injected outside git.
|
- Runtime secrets are injected outside git.
|
||||||
- `MASTER_KEY_B64` is set and backed up securely.
|
- `MASTER_KEY_B64` is set and backed up securely; restores of locally encrypted content fail closed without the exact matching key.
|
||||||
- Database backup and restore commands are tested.
|
- Database backup and restore commands are tested.
|
||||||
- File/object storage is durable and backed up.
|
- File/object storage is durable and backed up.
|
||||||
- `CORS_ORIGINS` and cookie settings match the deployed WebUI origin.
|
- `CORS_ORIGINS` and cookie settings match the deployed WebUI origin.
|
||||||
|
|||||||
@@ -9,12 +9,25 @@ operator, and roadmap pages.
|
|||||||
| Topic | Canonical document | Notes |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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
|
## Release And Operations
|
||||||
|
|
||||||
@@ -25,13 +38,18 @@ operator, and roadmap pages.
|
|||||||
| Release dependencies and catalogs | `RELEASE_DEPENDENCIES.md` | Release package refs, migration baselines, release lockfiles, catalog trust/licensing, catalog publishing, and release checklist. |
|
| 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. |
|
| 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. |
|
| 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
|
## Product And Module Planning
|
||||||
|
|
||||||
| Topic | Canonical document | Notes |
|
| Topic | Canonical document | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Product roadmap and module routing | `GOVOPLAN_MASTER_ROADMAP.md` | Product-level sequencing, implementation gates, issue routing, and missing-module decisions. |
|
| 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. |
|
| 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. |
|
| 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`. |
|
| 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. |
|
| Configuration packages | `CONFIGURATION_PACKAGES.md` | Package model, provider contract, import/export flow, and tracking slices. |
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Durable Recovery Operations
|
||||||
|
|
||||||
|
Modules must use `begin_durable_recovery_operation` for work whose effects can
|
||||||
|
outlive the caller's SQLAlchemy transaction. The helper commits the canonical
|
||||||
|
request hash, recovery plan, precondition evidence, running state, and lease
|
||||||
|
fence before the caller mutates object storage, a queue, a filesystem, or an
|
||||||
|
external provider.
|
||||||
|
|
||||||
|
Each later checkpoint is written through an independent database session. A
|
||||||
|
business-transaction rollback therefore cannot erase evidence of an earlier
|
||||||
|
effect. Successful completion requires concrete verification checks and a valid
|
||||||
|
hash chain. Compensation likewise records recovery-required, recovering, and
|
||||||
|
verified-recovered checkpoints rather than reporting an ordinary failure.
|
||||||
|
A definitive pre-effect or provider rejection records terminal `rejected`
|
||||||
|
evidence instead of being mislabeled as success, atomic rollback, or recovery
|
||||||
|
work.
|
||||||
|
|
||||||
|
If a runtime disappears, another runtime may claim the operation only after the
|
||||||
|
lease expires. The takeover records both fences. A stale compensatable operation
|
||||||
|
becomes recovery-required; a stale forward-only or irreversible external effect
|
||||||
|
becomes outcome-unknown; a database-only atomic operation is recorded failed
|
||||||
|
because its transaction rolled back. Takeover never re-executes the original
|
||||||
|
request automatically.
|
||||||
|
|
||||||
|
Evidence and metadata may contain opaque references, digests, counts, and
|
||||||
|
provider result codes. They must never contain credentials or resolved secrets.
|
||||||
|
Ops is the platform surface for unresolved operation status; owning modules must
|
||||||
|
provide the reconciliation action and business-level explanation.
|
||||||
|
|
||||||
|
Database-only operations must use the durable handle's atomic terminal methods
|
||||||
|
when their module rows and final recovery checkpoint belong to one invariant.
|
||||||
|
Those methods stage the terminal checkpoint and lease release in the caller's
|
||||||
|
SQLAlchemy transaction, then commit the domain rows and recovery evidence
|
||||||
|
together. A failed commit rolls both back and leaves the previously durable
|
||||||
|
`running` record available for stale-fence handling; modules must not commit
|
||||||
|
their domain state first and close an `atomic` recovery record afterwards.
|
||||||
|
|
||||||
|
An owning module may reconcile an `outcome_unknown` provider effect through the
|
||||||
|
claimed durable handle's `resolve_unknown` method. External evidence that the
|
||||||
|
effect occurred records verified success. Evidence that it did not occur moves
|
||||||
|
the operation through recovery-required and recovering to verified recovered,
|
||||||
|
so any later attempt must use a new deliberate idempotency key. The method does
|
||||||
|
not infer provider state and requires the same terminal verification structure
|
||||||
|
and hash-chain checks as ordinary completion.
|
||||||
+27
-19
@@ -8,25 +8,32 @@ module reactions, and operator diagnostics.
|
|||||||
|
|
||||||
## Production Transport Decision
|
## Production Transport Decision
|
||||||
|
|
||||||
The first production target is a **database outbox plus in-process immediate
|
The production transport is a **transactional database outbox plus retrying
|
||||||
dispatch**:
|
dispatcher**:
|
||||||
|
|
||||||
- Use `govoplan_core.core.events.PlatformEvent` for domain and platform events.
|
- Use `govoplan_core.core.events.PlatformEvent` for domain and platform events.
|
||||||
- Use `EventBus` as the in-process dispatch contract for same-process module
|
- Call `emit_platform_event(session, event)` to bind event delivery to the
|
||||||
reactions that are safe to run inline.
|
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
|
- Use the shared `audit_event` / `audit_from_principal` helper for audited
|
||||||
module actions. The helper persists the audit row and immediately publishes a
|
module actions. The helper persists the audit row and transactionally emits a
|
||||||
governed `PlatformEvent` whose `type` is the audit action.
|
governed `PlatformEvent` whose `type` is the audit action.
|
||||||
- Use `record_change` for module delta feeds. It persists the change-sequence
|
- Use `record_change` for module delta feeds. It persists the change-sequence
|
||||||
row and immediately publishes a generic module change event such as
|
row and transactionally emits a generic module change event such as
|
||||||
`mail.profile.updated`.
|
`mail.profile.updated`.
|
||||||
- Persist durable integration/workflow events through a database outbox before
|
- Drain the outbox with the Celery `govoplan.events.dispatch_outbox` task on the
|
||||||
acknowledging the state change that produced them.
|
`events` queue. The periodic schedule also retries pending rows.
|
||||||
- Drain the outbox through a small dispatcher process. The dispatcher may call
|
- The dispatcher invokes Dataflow event ingestion when that capability is
|
||||||
in-process handlers in the same deployment first, but its storage contract is
|
active, then publishes to the process-local bus.
|
||||||
database-backed.
|
|
||||||
- Treat Redis/Celery as worker/job infrastructure, not as the authoritative
|
- Treat Redis/Celery as worker/job infrastructure, not as the authoritative
|
||||||
first event transport. A Celery dispatcher can consume the outbox later.
|
first event transport. PostgreSQL remains authoritative until dispatch is
|
||||||
|
recorded.
|
||||||
- Keep the dispatch implementation pluggable behind the `PlatformEvent`
|
- Keep the dispatch implementation pluggable behind the `PlatformEvent`
|
||||||
envelope so a future message broker can be added without changing event
|
envelope so a future message broker can be added without changing event
|
||||||
producers.
|
producers.
|
||||||
@@ -44,17 +51,18 @@ Event producers should write their domain state and outbox event in the same
|
|||||||
database transaction wherever possible. Handlers must be idempotent because the
|
database transaction wherever possible. Handlers must be idempotent because the
|
||||||
outbox dispatcher can retry after a crash or timeout.
|
outbox dispatcher can retry after a crash or timeout.
|
||||||
|
|
||||||
Recommended first outbox columns:
|
The current outbox stores:
|
||||||
|
|
||||||
- `event_id`, `event_type`, `module_id`
|
- `event_id`, `event_type`, `module_id`
|
||||||
- `correlation_id`, `causation_id`
|
- `correlation_id`, `causation_id`
|
||||||
- `payload`, `occurred_at`
|
- `classification`, serialized event `payload`
|
||||||
- `available_at`, `attempt_count`, `claimed_at`, `claim_token`
|
- `status`, `attempts`, `next_attempt_at`
|
||||||
- `processed_at`, `last_error`
|
- `dispatched_at`, `last_error`, timestamps
|
||||||
|
|
||||||
Inline `EventBus` handlers are allowed only for non-critical local reactions.
|
Handlers must be idempotent: a worker may complete an external effect and fail
|
||||||
Anything that must survive process failure, restart, package update, or worker
|
before marking its outbox row dispatched. Anything that must survive process
|
||||||
redeployment belongs in the outbox.
|
failure, restart, package update, or worker redeployment requires the outbox
|
||||||
|
provider and dispatcher.
|
||||||
|
|
||||||
## Trace IDs
|
## Trace IDs
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# External References And Integration Maturity
|
||||||
|
|
||||||
|
GovOPlaN integrations use a shared external-reference contract instead of
|
||||||
|
storing connector-specific URLs and identifiers in every module.
|
||||||
|
|
||||||
|
An external reference identifies an object by:
|
||||||
|
|
||||||
|
- external system instance
|
||||||
|
- object type
|
||||||
|
- stable external object ID
|
||||||
|
- optional connector configuration
|
||||||
|
- canonical HTTP(S) URL without embedded credentials
|
||||||
|
- optional source version, ETag, observation time, and non-secret metadata
|
||||||
|
|
||||||
|
The identity key is `system:object_type:object_id`. A GovOPlaN object may retain
|
||||||
|
multiple references, but one reference must never silently change its identity.
|
||||||
|
Moving or escalating work creates a new object and an explicit relationship; it
|
||||||
|
does not rewrite either object's history.
|
||||||
|
|
||||||
|
## Integration Maturity
|
||||||
|
|
||||||
|
Maturity is cumulative:
|
||||||
|
|
||||||
|
1. `discover`: identify configured external systems and their health.
|
||||||
|
2. `link`: retain and open stable external references.
|
||||||
|
3. `search`: include authorized external objects in GovOPlaN search.
|
||||||
|
4. `read`: display authoritative external content.
|
||||||
|
5. `publish`: create or update external content from GovOPlaN.
|
||||||
|
6. `synchronize`: reconcile changes in both directions with conflict handling.
|
||||||
|
7. `migrate`: perform a governed, verifiable transfer into GovOPlaN.
|
||||||
|
8. `replace`: provide the native operational capability without the external tool.
|
||||||
|
|
||||||
|
Connectors must declare and document the maturity they actually implement.
|
||||||
|
`synchronize` requires durable cursors, idempotency, provenance, conflict
|
||||||
|
handling, deletion semantics, and observable failures. A link-only connector
|
||||||
|
must not imply that GovOPlaN holds an authoritative copy.
|
||||||
|
|
||||||
|
## Source Authority Is A Separate Dimension
|
||||||
|
|
||||||
|
Integration maturity states what an adapter is capable of doing. It does not
|
||||||
|
decide which system owns truth for a configured object or field group. A
|
||||||
|
binding separately selects one of the source-authority modes defined by the
|
||||||
|
[institutional governance target architecture](../../govoplan/docs/architecture/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md):
|
||||||
|
|
||||||
|
- `native_authoritative`
|
||||||
|
- `external_authoritative`
|
||||||
|
- `external_mirror`
|
||||||
|
- `governed_sync`
|
||||||
|
- `governance_overlay`
|
||||||
|
- `linked_reference`
|
||||||
|
|
||||||
|
A connector can therefore support `synchronize` while a tenant deliberately
|
||||||
|
uses it only as an external mirror. Conversely, a native GovOPlaN object may
|
||||||
|
retain link-only references to several external systems. Authority may be
|
||||||
|
narrowed by tenant, organization, service, object type, object, field group, or
|
||||||
|
process step and must be visible in provenance and configuration preflight.
|
||||||
|
|
||||||
|
## Domain Ownership
|
||||||
|
|
||||||
|
- Domain modules own native GovOPlaN objects and their authorization.
|
||||||
|
- Connectors own protocols, credentials, discovery, transport, and sync state.
|
||||||
|
- Search owns indexing and result aggregation, but source modules remain
|
||||||
|
responsible for authorization.
|
||||||
|
- Core owns only the stable DTOs and extension contracts.
|
||||||
|
|
||||||
|
The Python contract is
|
||||||
|
`govoplan_core.core.external_references.ExternalObjectReference`.
|
||||||
@@ -13,4 +13,4 @@ tools/gitea/gitea-sync-wiki.py --help
|
|||||||
|
|
||||||
Canonical documentation:
|
Canonical documentation:
|
||||||
|
|
||||||
- `/mnt/DATA/git/govoplan/docs/GITEA_ISSUES.md`
|
- `/mnt/DATA/git/govoplan/docs/project/GITEA_ISSUES.md`
|
||||||
|
|||||||
@@ -221,7 +221,8 @@ Admin lists use bounded container grids:
|
|||||||
- recipient import with column mapping;
|
- recipient import with column mapping;
|
||||||
- session/device revocation UI;
|
- session/device revocation UI;
|
||||||
- backup/restore, monitoring, and update procedures;
|
- backup/restore, monitoring, and update procedures;
|
||||||
- DSAR workflows and evidence bundle verifier;
|
- additional module providers and signed human-readable response packages for
|
||||||
|
the implemented DSAR workflow described in `DATA_SUBJECT_REQUESTS.md`;
|
||||||
- campaign ownership transfer workflow;
|
- campaign ownership transfer workflow;
|
||||||
- policy impact analysis before delete/disable/unshare/change;
|
- policy impact analysis before delete/disable/unshare/change;
|
||||||
- LDAP/OIDC/SAML provisioning;
|
- LDAP/OIDC/SAML provisioning;
|
||||||
|
|||||||
+110
-95
@@ -10,12 +10,15 @@ gates. Issues are the active backlog; this document is durable architecture
|
|||||||
planning context and should be mirrored to the Gitea wiki.
|
planning context and should be mirrored to the Gitea wiki.
|
||||||
|
|
||||||
The meta repository's
|
The meta repository's
|
||||||
[Connected Governance Platform Roadmap](https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
|
[GovOPlaN Roadmap](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/strategy/ROADMAP.md)
|
||||||
describes the corresponding cross-product stakeholder visions, configurable
|
describes the corresponding cross-product stakeholder visions, configurable
|
||||||
service and operating configurations, connected outcome stories, and
|
service and operating configurations, connected outcome stories, and
|
||||||
capability horizons. The selected five-stage delivery sequence and its gates
|
capability horizons. The selected five-stage delivery sequence and its gates
|
||||||
are in the meta repository's
|
are in the meta repository's
|
||||||
[Reference Journey Program](https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/REFERENCE_JOURNEY_PROGRAM.md).
|
[Reference Journey Program](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/strategy/REFERENCE_JOURNEY_PROGRAM.md).
|
||||||
|
The semantic target, source-authority modes, and reconciliation with the
|
||||||
|
implemented platform are in the meta repository's
|
||||||
|
[Institutional Governance Target Architecture](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/architecture/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
|
||||||
Those product documents are canonical; this Core roadmap remains their
|
Those product documents are canonical; this Core roadmap remains their
|
||||||
technical sequencing and module-routing companion.
|
technical sequencing and module-routing companion.
|
||||||
|
|
||||||
@@ -64,8 +67,9 @@ verify or reverse those effects.
|
|||||||
`INTERFACE_ETHICS_AND_DESIGN_DOCTRINE.md`.
|
`INTERFACE_ETHICS_AND_DESIGN_DOCTRINE.md`.
|
||||||
- Automation must use governed action/effect contracts, not hidden side
|
- Automation must use governed action/effect contracts, not hidden side
|
||||||
effects. The first automation layer is defined in
|
effects. The first automation layer is defined in
|
||||||
`ACTION_EFFECT_AUTOMATION_LAYER.md` and should start in `govoplan-workflow`
|
`ACTION_EFFECT_AUTOMATION_LAYER.md`; its first runner lives in
|
||||||
unless a separate automation module becomes justified.
|
`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
|
- Encrypted postboxes are a strategic target. Early postbox, access, and
|
||||||
identity-trust contracts should stay compatible with the E2EE architecture in
|
identity-trust contracts should stay compatible with the E2EE architecture in
|
||||||
`POSTBOX_E2EE_ARCHITECTURE.md`.
|
`POSTBOX_E2EE_ARCHITECTURE.md`.
|
||||||
@@ -144,8 +148,9 @@ pattern exists.
|
|||||||
| Structured forms and validation | `govoplan-forms` |
|
| Structured forms and validation | `govoplan-forms` |
|
||||||
| Uploaded files and managed storage | `govoplan-files` |
|
| Uploaded files and managed storage | `govoplan-files` |
|
||||||
| Case record and lifecycle | `govoplan-cases` |
|
| Case record and lifecycle | `govoplan-cases` |
|
||||||
| Workflow transitions and automation | `govoplan-workflow` |
|
| Workflow runtime, transitions, and automation | `govoplan-workflow-engine` |
|
||||||
| Action/effect catalogue and automation runner | first `govoplan-workflow`; possible future `govoplan-automation` if it outgrows workflow |
|
| 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` |
|
| Internal work queues and tasks | `govoplan-tasks` |
|
||||||
| Appointment proposals and booking | `govoplan-appointments`, `govoplan-calendar` |
|
| Appointment proposals and booking | `govoplan-appointments`, `govoplan-calendar` |
|
||||||
| Postbox, email, and notifications | `govoplan-postbox`, `govoplan-mail`, `govoplan-notifications` |
|
| Postbox, email, and notifications | `govoplan-postbox`, `govoplan-mail`, `govoplan-notifications` |
|
||||||
@@ -153,13 +158,18 @@ pattern exists.
|
|||||||
| Organizational structures, units, and functions | `govoplan-organizations` |
|
| Organizational structures, units, and functions | `govoplan-organizations` |
|
||||||
| Identity-to-function assignments and directory synchronization | `govoplan-idm` |
|
| 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` |
|
| Identity trust, device keys, and encrypted postbox key contracts | `govoplan-identity-trust`, `govoplan-access`, `govoplan-postbox` |
|
||||||
| Service directory/catalog | `govoplan-portal` |
|
| Service directory presentation | `govoplan-portal` |
|
||||||
|
| Versioned institutional service definition | shared service contract first; candidate `govoplan-services` after reuse proof |
|
||||||
|
| Mandate, jurisdiction, and institutional authority | shared mandate contract first; candidate `govoplan-mandates` after reuse proof |
|
||||||
|
| Procedure-local parties and representation | shared party contract first; candidate `govoplan-parties` after reuse proof |
|
||||||
|
| Formal institutional decisions | shared decision contract first; candidate `govoplan-decisions` after reuse proof |
|
||||||
| Permit/document generation | `govoplan-templates`, `govoplan-dms` |
|
| Permit/document generation | `govoplan-templates`, `govoplan-dms` |
|
||||||
| Payment capture and accounting handoff | `govoplan-payments`, `govoplan-ledger` |
|
| Payment capture and accounting handoff | `govoplan-payments`, `govoplan-ledger` |
|
||||||
| Authentication projection, roles, permissions, acting context | `govoplan-access` |
|
| Authentication projection, roles, permissions, acting context | `govoplan-access` |
|
||||||
| Tenants, policy, and audit | `govoplan-tenancy`, `govoplan-policy`, `govoplan-audit` |
|
| Tenants, policy, and audit | `govoplan-tenancy`, `govoplan-policy`, `govoplan-audit` |
|
||||||
| External software integration | `govoplan-connectors` |
|
| External software integration | `govoplan-connectors` |
|
||||||
| Recurring extraction and transformation | possible future `govoplan-datasources`, possible future `govoplan-dataflow` |
|
| Governed data/register catalogue | `govoplan-datasources` |
|
||||||
|
| Recurring extraction and transformation | `govoplan-dataflow` over Datasources and Connectors contracts |
|
||||||
| Reports, BI, and management visibility | `govoplan-reporting` |
|
| Reports, BI, and management visibility | `govoplan-reporting` |
|
||||||
|
|
||||||
## Configuration And Safety Target
|
## Configuration And Safety Target
|
||||||
@@ -204,8 +214,9 @@ an editor applies a high-impact configuration change.
|
|||||||
|
|
||||||
## Reference Journeys
|
## Reference Journeys
|
||||||
|
|
||||||
The active sequence is selected. Workflow remains deliberately deferred and is
|
The active sequence is selected. Workflow Engine and its optional editor are
|
||||||
not a dependency of these journeys.
|
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
|
### Journey 1: Campaign Demonstration Composition
|
||||||
|
|
||||||
@@ -256,9 +267,11 @@ The result must preserve official-key mappings, organizational and reporting
|
|||||||
date semantics, quality findings, quarantine/replay, transparent calculation,
|
date semantics, quality findings, quarantine/replay, transparent calculation,
|
||||||
and reproducible promotion between development, test, and production.
|
and reproducible promotion between development, test, and production.
|
||||||
|
|
||||||
Reporting consumes the product. Create `govoplan-datasources` or
|
Reporting consumes the product. Datasources owns the governed source and
|
||||||
`govoplan-dataflow` only after the concrete path proves repeated ownership that
|
materialization lifecycle; Dataflow owns typed transformation/run lineage;
|
||||||
does not belong to connectors, Reporting, or the producing domain module.
|
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
|
### Journey 5: Collaborative Document Lifecycle
|
||||||
|
|
||||||
@@ -336,8 +349,9 @@ Create or refine in this order:
|
|||||||
access.
|
access.
|
||||||
4. `govoplan-cases`: case record, status, assignments, deadlines, and case
|
4. `govoplan-cases`: case record, status, assignments, deadlines, and case
|
||||||
evidence.
|
evidence.
|
||||||
5. `govoplan-workflow`: state machine, transitions, commands, and module
|
5. `govoplan-workflow-engine`: state machine, transitions, commands, module
|
||||||
handoff.
|
handoff, and resumable execution; optional `govoplan-workflow` supplies the
|
||||||
|
editor.
|
||||||
6. `govoplan-tasks`: work queues, assignments, due dates, and follow-ups.
|
6. `govoplan-tasks`: work queues, assignments, due dates, and follow-ups.
|
||||||
7. `govoplan-templates`: permit/decision document generation.
|
7. `govoplan-templates`: permit/decision document generation.
|
||||||
8. `govoplan-postbox`, `govoplan-mail`, `govoplan-notifications`: applicant and
|
8. `govoplan-postbox`, `govoplan-mail`, `govoplan-notifications`: applicant and
|
||||||
@@ -412,7 +426,7 @@ Goal: cover internal support and public issue reporting.
|
|||||||
|
|
||||||
Create or refine in this order:
|
Create or refine in this order:
|
||||||
|
|
||||||
1. `govoplan-issue-reporting`: public/internal reports, categories, intake,
|
1. `govoplan-tickets`: public/internal reports, requests, incidents, queues,
|
||||||
location, evidence, and triage.
|
location, evidence, and triage.
|
||||||
2. `govoplan-helpdesk`: service desk tickets, queues, SLAs, assignments,
|
2. `govoplan-helpdesk`: service desk tickets, queues, SLAs, assignments,
|
||||||
escalation, and resolution evidence.
|
escalation, and resolution evidence.
|
||||||
@@ -526,31 +540,34 @@ Refine:
|
|||||||
dashboard data.
|
dashboard data.
|
||||||
- `govoplan-search`: permissioned cross-module discovery.
|
- `govoplan-search`: permissioned cross-module discovery.
|
||||||
|
|
||||||
Create only when justified:
|
Refine the existing owners:
|
||||||
|
|
||||||
- `govoplan-datasources`: source catalog, connection profiles, schema discovery,
|
- `govoplan-datasources`: governed data/register catalog, live/cached/static
|
||||||
freshness, provenance.
|
sources, staging, immutable materializations, freshness, quality, legal and
|
||||||
- `govoplan-dataflow`: transformations, validation, lineage, scheduled runs,
|
organizational context, and provenance. Connector profiles and credentials
|
||||||
publication outputs.
|
remain in Connectors.
|
||||||
- `govoplan-projects`: only if OpenProject connectors and existing cases/tasks
|
- `govoplan-dataflow`: typed transformations, validation, lineage, manual,
|
||||||
cannot cover the required semantics.
|
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,
|
Reference journey: monthly data extraction, transformation, validation, approval,
|
||||||
publication, and reporting.
|
publication, and reporting.
|
||||||
|
|
||||||
Recurring extraction/transformation should start as a configuration package
|
Recurring extraction/transformation should be delivered as a configuration
|
||||||
across connectors, files, workflow, reporting, and templates. The package should
|
package across Connectors, Datasources, Dataflow, Workflow Engine, Reporting,
|
||||||
register sources, declare schemas, define mapping/validation versions, schedule
|
Files, and Templates. The package should register sources, declare schemas,
|
||||||
runs, produce previewable diffs, write governed outputs, and preserve lineage,
|
define mapping/validation versions, schedule runs, produce previewable diffs,
|
||||||
hashes, operator actions, and audit evidence. Create `govoplan-datasources` or
|
write governed outputs, and preserve lineage, hashes, operator actions, and
|
||||||
`govoplan-dataflow` only after this work exposes repeated contracts that do not
|
audit evidence.
|
||||||
belong to existing modules.
|
|
||||||
|
|
||||||
Exit criteria:
|
Exit criteria:
|
||||||
|
|
||||||
- connector catalog exists before building many adapters
|
- connector catalog exists before building many adapters
|
||||||
- dataflow is created only after recurring transformation becomes product
|
- datasource and dataflow ownership remains provider-neutral and is proved by
|
||||||
behavior
|
the recurring transformation package
|
||||||
- reporting consumes governed sources with provenance
|
- reporting consumes governed sources with provenance
|
||||||
|
|
||||||
## Implementation Gates
|
## Implementation Gates
|
||||||
@@ -591,23 +608,25 @@ in the capability waves:
|
|||||||
4. Implement one data-backed Templates/Reporting path and safe HIS-style deep
|
4. Implement one data-backed Templates/Reporting path and safe HIS-style deep
|
||||||
launch.
|
launch.
|
||||||
5. Extend that concrete source into one governed university analytical data
|
5. Extend that concrete source into one governed university analytical data
|
||||||
product before generalizing data-source or dataflow ownership.
|
product and use it to harden the existing Datasources/Dataflow ownership,
|
||||||
|
quality, lineage, and promotion contracts.
|
||||||
6. Implement Files-backed DMS versions and one provider-neutral collaborative
|
6. Implement Files-backed DMS versions and one provider-neutral collaborative
|
||||||
editing lifecycle, then connect Records handoff.
|
editing lifecycle, then connect Records handoff.
|
||||||
7. Maintain already integrated Calendar/Scheduling/Poll and other foundations;
|
7. Maintain already integrated Calendar/Scheduling/Poll and other foundations;
|
||||||
activate another capability cluster only when the current journey needs it
|
activate another capability cluster only when the current journey needs it
|
||||||
or the product roadmap explicitly reprioritizes it.
|
or the product roadmap explicitly reprioritizes it.
|
||||||
8. Resume Workflow only by explicit product decision and constrain it with
|
8. Extend Workflow Engine and the optional editor only through stable actions
|
||||||
stable actions from one demonstrated package.
|
and one demonstrated package at a time.
|
||||||
|
|
||||||
## Deliberate Deferrals
|
## Deliberate Deferrals
|
||||||
|
|
||||||
Defer these until a reference journey proves the need:
|
Defer these until a reference journey proves the need:
|
||||||
|
|
||||||
- full ERP replacement
|
- full ERP replacement
|
||||||
- native project management beyond connector support
|
- unsupported breadth in native project management before the Projects/OpenProject
|
||||||
- an unbounded general-purpose dataflow platform; the bounded governed BI
|
boundary is proved in a reference journey
|
||||||
reference journey is selected
|
- unbounded Dataflow operators or execution engines without golden-flow,
|
||||||
|
quality, lineage, resource-limit, and recovery evidence
|
||||||
- every possible public-sector protocol adapter
|
- every possible public-sector protocol adapter
|
||||||
- rich LMS behavior beyond training administration
|
- rich LMS behavior beyond training administration
|
||||||
- full qualified digital signing/trust services beyond the identity-trust and
|
- full qualified digital signing/trust services beyond the identity-trust and
|
||||||
@@ -625,73 +644,69 @@ repositories or to explicit missing-module decisions.
|
|||||||
|
|
||||||
| Idea | Owner | Tracking |
|
| Idea | Owner | Tracking |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Government operations backbone reference model | `govoplan-core` | `add-ideas/govoplan-core#213` |
|
| Government operations backbone reference model | `govoplan-core` | `GovOPlaN/govoplan-core#213` |
|
||||||
| Permit-to-payment configuration package | `govoplan-core` plus participating modules | `add-ideas/govoplan-core#214` |
|
| 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` | `add-ideas/govoplan-core#218` |
|
| 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` | `add-ideas/govoplan-access#7` |
|
| Access as a module | `govoplan-access` | `GovOPlaN/govoplan-access#7` |
|
||||||
| Interface ethics and decision-consequence doctrine | `govoplan-core` plus all UI-owning modules | `add-ideas/govoplan-core#227` |
|
| Interface ethics and decision-consequence doctrine | `govoplan-core` plus all UI-owning modules | `GovOPlaN/govoplan-core#227` |
|
||||||
| Action/effect automation layer | first `govoplan-workflow`; possible future `govoplan-automation` | `add-ideas/govoplan-workflow#1` |
|
| 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` | `add-ideas/govoplan-postbox#15`, `add-ideas/govoplan-identity-trust#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` | `add-ideas/govoplan-access#9` |
|
| Identity, account, function, role, right semantic model | `govoplan-access` | `GovOPlaN/govoplan-access#9` |
|
||||||
| Role-based service directory/catalog | `govoplan-portal` | `add-ideas/govoplan-portal#1` |
|
| 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 | `add-ideas/govoplan-tasks#1`, `add-ideas/govoplan-notifications#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` | `add-ideas/govoplan-connectors#1` |
|
| OpenProject API / project management connector | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#1` |
|
||||||
| Native project-management module decision | connector-first through `govoplan-connectors`; no native project module yet | `add-ideas/govoplan-core#196`, `add-ideas/govoplan-connectors#1` |
|
| 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 | no repository yet; start with connectors/files/reporting and create `govoplan-datasources` only after the first package proves shared source-catalog ownership | `add-ideas/govoplan-core#197` |
|
| 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 | no repository yet; start with workflow/reporting/connectors and create `govoplan-dataflow` only after repeated pipeline/lineage contracts emerge | `add-ideas/govoplan-core#198` |
|
| Dataflow for pipelines, BI, publication | `govoplan-dataflow` over Datasources and module-owned providers | `GovOPlaN/govoplan-dataflow#1` |
|
||||||
| Monthly datasource and transformation workflows | first as configuration package across connectors, files, workflow, reporting, and templates | `add-ideas/govoplan-core#216` |
|
| 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 | `add-ideas/govoplan-core#190`, `add-ideas/govoplan-templates#1` |
|
| 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 | `add-ideas/govoplan-core#190`, `add-ideas/govoplan-reporting#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` | `add-ideas/govoplan-files#15` |
|
| File connectors: Nextcloud, Seafile, SMB, NFS | `govoplan-files` | `GovOPlaN/govoplan-files#15` |
|
||||||
| Public-sector software integration catalogue | `govoplan-connectors` with core strategy index | `add-ideas/govoplan-core#191`, `add-ideas/govoplan-connectors#2` |
|
| Public-sector 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 | `add-ideas/govoplan-core#215` |
|
| Public-sector integration landscape catalogue | `govoplan-connectors` with core tracking | `GovOPlaN/govoplan-core#215` |
|
||||||
| Cases module concept | `govoplan-cases` | `add-ideas/govoplan-core#174` |
|
| Cases module concept | `govoplan-cases` | `GovOPlaN/govoplan-core#174` |
|
||||||
| Workflow module concept | `govoplan-workflow` | `add-ideas/govoplan-core#175` |
|
| 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` | `add-ideas/govoplan-core#176` |
|
| Connectors module concept | `govoplan-connectors` | `GovOPlaN/govoplan-core#176` |
|
||||||
| Adrema-style address and distribution-list management | `govoplan-addresses` | `add-ideas/govoplan-addresses#1` |
|
| Adrema-style address and distribution-list management | `govoplan-addresses` | `GovOPlaN/govoplan-addresses#1` |
|
||||||
| Consume sources and become a governed source | `govoplan-connectors` plus possible future `govoplan-dataflow` | `add-ideas/govoplan-connectors#3`, `add-ideas/govoplan-core#198` |
|
| 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` | `add-ideas/govoplan-connectors#6` |
|
| 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 | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-scheduling#1` |
|
| 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` | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-calendar#1` |
|
| Terminplaner and calendar primitives | `govoplan-calendar` | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-calendar#1` |
|
||||||
| Terminbuchung appointment booking | `govoplan-appointments` | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-appointments#1` |
|
| Terminbuchung appointment booking | `govoplan-appointments` | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-appointments#1` |
|
||||||
| Collaborative documents | `govoplan-dms` | `add-ideas/govoplan-dms#1` |
|
| Collaborative documents | `govoplan-dms` | `GovOPlaN/govoplan-dms#1` |
|
||||||
| Forms | `govoplan-forms` for definitions and `govoplan-forms-runtime` for submissions/runtime behavior | `add-ideas/govoplan-core#194`, `add-ideas/govoplan-forms#1` |
|
| 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` | `add-ideas/govoplan-connectors#4` |
|
| RSS consume and emit | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#4` |
|
||||||
| LDAP, Active Directory, OpenDesk identity | `govoplan-idm` | `add-ideas/govoplan-idm#1` |
|
| 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 | `add-ideas/govoplan-core#195`, `add-ideas/govoplan-connectors#5` |
|
| 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` | `add-ideas/govoplan-mail#5` |
|
| Open-Xchange mail/groupware | `govoplan-mail` | `GovOPlaN/govoplan-mail#5` |
|
||||||
| Open-Xchange calendar | `govoplan-calendar` | `add-ideas/govoplan-calendar#2` |
|
| Open-Xchange calendar | `govoplan-calendar` | `GovOPlaN/govoplan-calendar#2` |
|
||||||
| Scalability profiles and autoscaling readiness | `govoplan-ops`, `govoplan-core` | `add-ideas/govoplan-core#217` |
|
| Scalability profiles and autoscaling readiness | `govoplan-ops`, `govoplan-core` | `GovOPlaN/govoplan-core#217` |
|
||||||
| Hardware sizing matrix and requirements calculator | `govoplan-ops`, `govoplan-core` | `add-ideas/govoplan-core#219` |
|
| 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` | `add-ideas/govoplan-core#220` |
|
| 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` | `add-ideas/govoplan-core#19` |
|
| Install/runtime configuration contract | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#19` |
|
||||||
| Installer/deployment operator flow | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#26` |
|
| Installer/deployment operator flow | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#26` |
|
||||||
| Production-like deployment documentation | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#28` |
|
| Production-like deployment documentation | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#28` |
|
||||||
|
|
||||||
Boundary rationale lives in `MODULE_ARCHITECTURE.md`. Current decisions:
|
Boundary rationale lives in `MODULE_ARCHITECTURE.md`. Current decisions:
|
||||||
|
|
||||||
- templates and reporting are separate modules
|
- templates and reporting are separate modules
|
||||||
- RSS/source consume-publish starts in connectors; datasources/dataflow are not
|
- RSS/source consume-publish starts in Connectors; governed source identity and
|
||||||
repositories yet
|
snapshots belong to Datasources and transformations belong to Dataflow
|
||||||
- calendar, scheduling, and appointments are three separate modules
|
- calendar, scheduling, and appointments are three separate modules
|
||||||
- forms definitions and forms runtime are separate responsibilities
|
- forms definitions and forms runtime are separate responsibilities
|
||||||
- OpenDesk is an integration profile across modules, not a monolithic module
|
- OpenDesk is an integration profile across modules, not a monolithic module
|
||||||
- OpenProject is connector-first; no native projects module yet
|
- OpenProject transport is connector-owned; native portfolio/project semantics
|
||||||
|
belong to Projects
|
||||||
- public-sector integration strategy stays in core; executable catalogue work
|
- public-sector integration strategy stays in core; executable catalogue work
|
||||||
lives in connectors
|
lives in connectors
|
||||||
- encrypted postbox and identity-trust are strategic contracts, not mail-module
|
- encrypted postbox and identity-trust are strategic contracts, not mail-module
|
||||||
behavior
|
behavior
|
||||||
- automation starts as workflow-owned action/effect execution and may split into
|
- automation starts in Workflow Engine and may split into a dedicated module
|
||||||
a dedicated module only after the runner becomes broader than workflow
|
only after the runner becomes broader than workflow
|
||||||
|
- Mandates, Services, Parties, and Decisions begin as shared semantic contracts;
|
||||||
The following modules are intentionally not created yet:
|
create repositories only after independent persistence, lifecycle, security,
|
||||||
|
and multiple-consumer evidence passes the repository threshold in the
|
||||||
- `govoplan-datasources`
|
institutional governance target architecture
|
||||||
- `govoplan-dataflow`
|
|
||||||
- `govoplan-projects`
|
|
||||||
|
|
||||||
Create a repository only after a concrete implementation package proves that
|
|
||||||
existing connector, files, reporting, workflow, or task ownership is too narrow.
|
|
||||||
|
|
||||||
Core keeps the strategy index in
|
Core keeps the strategy index in
|
||||||
`PUBLIC_SECTOR_INTEGRATION_STRATEGY.md`: integration postures, default
|
`PUBLIC_SECTOR_INTEGRATION_STRATEGY.md`: integration postures, default
|
||||||
@@ -705,7 +720,7 @@ Release composition and tag-only repository handling are documented in
|
|||||||
## Next Practical Work
|
## Next Practical Work
|
||||||
|
|
||||||
The active cross-product story is
|
The active cross-product story is
|
||||||
[`add-ideas/govoplan#14`](https://git.add-ideas.de/add-ideas/govoplan/issues/14).
|
[`GovOPlaN/govoplan#14`](https://git.add-ideas.de/GovOPlaN/govoplan/issues/14).
|
||||||
Module repositories own implementation issues; do not clone their state here.
|
Module repositories own implementation issues; do not clone their state here.
|
||||||
|
|
||||||
Immediate issue buckets:
|
Immediate issue buckets:
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
```
|
||||||
+401
-10
@@ -13,6 +13,10 @@ Policy decision, source provenance, and explain-response contracts are tracked
|
|||||||
in [`POLICY_CONTRACTS.md`](POLICY_CONTRACTS.md).
|
in [`POLICY_CONTRACTS.md`](POLICY_CONTRACTS.md).
|
||||||
The experimental remote WebUI bundle loading design is tracked in
|
The experimental remote WebUI bundle loading design is tracked in
|
||||||
[`REMOTE_WEBUI_BUNDLES.md`](REMOTE_WEBUI_BUNDLES.md).
|
[`REMOTE_WEBUI_BUNDLES.md`](REMOTE_WEBUI_BUNDLES.md).
|
||||||
|
The cross-product semantic layers, source-authority modes, and candidate
|
||||||
|
Mandates, Services, Parties, and Decisions boundaries are canonical in the
|
||||||
|
meta repository's
|
||||||
|
[`INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md`](../../govoplan/docs/architecture/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
|
||||||
|
|
||||||
## Layer Model
|
## Layer Model
|
||||||
|
|
||||||
@@ -24,6 +28,36 @@ The experimental remote WebUI bundle loading design is tracked in
|
|||||||
| Business modules | Public-sector workflows | campaigns, cases, forms, approvals, appointments |
|
| Business modules | Public-sector workflows | campaigns, cases, forms, approvals, appointments |
|
||||||
| Connector modules | External system integration | FIT-Connect, XÖV/XTA, DMS/eAkte, ERP, IDM |
|
| Connector modules | External system integration | FIT-Connect, XÖV/XTA, DMS/eAkte, ERP, IDM |
|
||||||
|
|
||||||
|
This table is the technical composition model. The product portfolio uses a
|
||||||
|
more detailed institutional layer model, but it does not change dependency
|
||||||
|
direction: Core provides contracts and composition; modules own semantics;
|
||||||
|
packages compose modules.
|
||||||
|
|
||||||
|
## Institutional Semantic Boundaries
|
||||||
|
|
||||||
|
Cross-module references must keep these answers distinct:
|
||||||
|
|
||||||
|
- Organizations owns where structures, units, and functions exist.
|
||||||
|
- Identity owns who a subject is; Access owns accounts, roles, permissions,
|
||||||
|
and authorization decisions; IDM owns effective function assignments.
|
||||||
|
- A Mandates capability will answer why a unit or function is competent for a
|
||||||
|
task, jurisdiction, subject, or period. It must not become another RBAC
|
||||||
|
system.
|
||||||
|
- A Services capability will own versioned institutional service definitions;
|
||||||
|
Portal presents and starts them.
|
||||||
|
- A Parties capability will own procedure-local participant roles,
|
||||||
|
representation, and delivery authority; it must reference rather than copy
|
||||||
|
Identity, Organizations, and Addresses subjects.
|
||||||
|
- A Decisions capability will own formal institutional outcomes and their
|
||||||
|
authority, facts, rules, reasoning, effects, correction, and review.
|
||||||
|
Approvals owns review gates, Committee owns deliberation/votes, and Workflow
|
||||||
|
Engine owns coordination.
|
||||||
|
|
||||||
|
Start each missing concept as a versioned DTO/provider contract used by a
|
||||||
|
bounded journey. A repository is justified only when the concept gains
|
||||||
|
independent persistence, lifecycle, security/operations behavior, release
|
||||||
|
reason, and reuse. Core must not store these domain objects.
|
||||||
|
|
||||||
## Kernel Responsibilities
|
## Kernel Responsibilities
|
||||||
|
|
||||||
The kernel target owns:
|
The kernel target owns:
|
||||||
@@ -74,6 +108,10 @@ The compatibility/deprecation plan for the current split line is:
|
|||||||
- reject new cross-module imports that bypass manifests, capabilities, events,
|
- reject new cross-module imports that bypass manifests, capabilities, events,
|
||||||
or public module APIs
|
or public module APIs
|
||||||
|
|
||||||
|
The retention windows and removal checklist for database bridges,
|
||||||
|
configuration/export schemas, and runtime/API aliases are defined in
|
||||||
|
`COMPATIBILITY_POLICY.md`.
|
||||||
|
|
||||||
## Stable Kernel Contracts
|
## Stable Kernel Contracts
|
||||||
|
|
||||||
The following contracts are the baseline API that modules can rely on:
|
The following contracts are the baseline API that modules can rely on:
|
||||||
@@ -87,20 +125,59 @@ The following contracts are the baseline API that modules can rely on:
|
|||||||
- capability factory contract
|
- capability factory contract
|
||||||
- access DTO/protocol contracts in `govoplan_core.core.access`
|
- access DTO/protocol contracts in `govoplan_core.core.access`
|
||||||
- resource ACL provider contract
|
- resource ACL provider contract
|
||||||
- tenant summary provider contract
|
- bounded reference-option search provider contract
|
||||||
|
- single-tenant and optional batched tenant summary provider contracts
|
||||||
- tenant delete-veto provider contract
|
- tenant delete-veto provider contract
|
||||||
- WebUI module contribution contract
|
- WebUI module contribution contract
|
||||||
- navigation metadata contract
|
- navigation metadata contract
|
||||||
- command/event envelope contract
|
- command/event envelope contract
|
||||||
- policy decision and source provenance contract in `govoplan_core.core.policy`
|
- policy decision and source provenance contract in `govoplan_core.core.policy`
|
||||||
|
- external object reference and integration-maturity contract in
|
||||||
|
`govoplan_core.core.external_references`
|
||||||
|
- action/effect preview and execution contract in
|
||||||
|
`govoplan_core.core.automation`
|
||||||
|
- workflow definition contribution and runtime-worker contracts
|
||||||
|
|
||||||
Changes to these contracts must be versioned or accompanied by compatibility shims.
|
Changes to these contracts must be versioned or accompanied by compatibility shims.
|
||||||
|
|
||||||
|
Tenant list pages prefer `tenant_summary_batch_providers`. A batch provider
|
||||||
|
receives the unique tenant IDs on the current page and returns count mappings
|
||||||
|
keyed by tenant ID. Missing tenant keys mean that the provider has no counts for
|
||||||
|
that tenant; provider errors remain visible. Modules that expose only the
|
||||||
|
single-tenant contract remain compatible through a per-tenant fallback.
|
||||||
|
Destructive tenant lifecycle planning deliberately continues to use the
|
||||||
|
single-tenant path so it invokes every registered provider for the target
|
||||||
|
tenant, independent of ordinary list-page projections.
|
||||||
|
|
||||||
This list is the Milestone A kernel-contract freeze baseline. New module work
|
This list is the Milestone A kernel-contract freeze baseline. New module work
|
||||||
may extend the kernel by adding explicit contracts, but existing contracts must
|
may extend the kernel by adding explicit contracts, but existing contracts must
|
||||||
remain source-compatible through the 0.1.x split line unless a migration shim
|
remain source-compatible through the 0.1.x split line unless a migration shim
|
||||||
and deprecation note are provided.
|
and deprecation note are provided.
|
||||||
|
|
||||||
|
### Architecture Metadata
|
||||||
|
|
||||||
|
`ModuleManifest.architecture` is the backward-compatible, versioned product-
|
||||||
|
portfolio declaration for:
|
||||||
|
|
||||||
|
- module kind and institutional architecture layer;
|
||||||
|
- evidence-backed maturity claim (`concept`, `scaffold`, `vertical_slice`,
|
||||||
|
`reference_ready`, `supported`, or `lts`);
|
||||||
|
- owned and explicitly non-owned concepts;
|
||||||
|
- supported source-authority modes;
|
||||||
|
- reference packages, tested providers, and known limits;
|
||||||
|
- migration, upgrade, recovery, security, operations, and documentation
|
||||||
|
evidence references.
|
||||||
|
|
||||||
|
Core validates the claim and all provider references during registry startup.
|
||||||
|
`reference_ready`, `supported`, and `lts` claims require a named reference
|
||||||
|
package and the cumulative evidence set; target-tested providers additionally
|
||||||
|
require provider evidence. A supported module with migrations must include
|
||||||
|
migration evidence. Signed release catalogs retain and revalidate the
|
||||||
|
declaration. The meta manifest check validates repository evidence paths and
|
||||||
|
provides an opt-in `--require-architecture` rollout gate. Platform metadata,
|
||||||
|
Docs, and Ops project the same declaration. A module cannot make itself
|
||||||
|
supported solely by changing its maturity string.
|
||||||
|
|
||||||
Known access-related capability names are defined in
|
Known access-related capability names are defined in
|
||||||
`govoplan_core.core.access`, including:
|
`govoplan_core.core.access`, including:
|
||||||
|
|
||||||
@@ -130,10 +207,49 @@ Other stable runtime capabilities currently include:
|
|||||||
|
|
||||||
- `identity.directory` and `identity.search`
|
- `identity.directory` and `identity.search`
|
||||||
- `organizations.directory`
|
- `organizations.directory`
|
||||||
- `idm.directory`
|
- `idm.directory`, `idm.function_assignments`, `idm.relationships`, and
|
||||||
- `calendar.outbox` and `calendar.scheduling`
|
`idm.assignment_lifecycle`
|
||||||
|
- `calendar.outbox`, `calendar.scheduling`, `calendar.invitations`, and
|
||||||
|
`calendar.externalProfiles`
|
||||||
- `poll.scheduling`
|
- `poll.scheduling`
|
||||||
- `notifications.dispatch`
|
- `notifications.dispatch`
|
||||||
|
- `application_status.projection`
|
||||||
|
- `payments.requests`
|
||||||
|
- `workflow.definitionContributions` and `workflow.runtimeWorker`
|
||||||
|
|
||||||
|
`calendar.scheduling` keeps workflow modules independent of Calendar-owned
|
||||||
|
models and transport adapters. Consumers may create tentative events, promote
|
||||||
|
the selected event in place, and release unused events idempotently. The
|
||||||
|
provider returns bounded external-delivery and outbox references so consumers
|
||||||
|
can retain retry state without copying Calendar's synchronization internals.
|
||||||
|
|
||||||
|
`application_status.projection` lets a presentation module resolve the tenant
|
||||||
|
and display or request access to an owner-supplied, deliberately bounded
|
||||||
|
applicant-status view. The provider retains policy, authorization, token, and
|
||||||
|
record ownership; consumers must not query provider tables or enlarge the
|
||||||
|
projection.
|
||||||
|
|
||||||
|
`payments.requests` carries replay-safe payment obligations and evidence-bound
|
||||||
|
manual reconciliation across module boundaries. Procedure modules identify the
|
||||||
|
source Case or Workflow in the command and retain the returned payment ID;
|
||||||
|
Payments remains authoritative for amount, currency, state, transaction
|
||||||
|
reference, and reconciliation evidence. Ledger, invoice, and external payment
|
||||||
|
providers remain separate follow-on contracts.
|
||||||
|
|
||||||
|
The provider-neutral `idm.relationships` contract carries tenant-scoped typed
|
||||||
|
groups, effective-dated identity relationships, and explicit membership
|
||||||
|
decisions. It deliberately does not expose IDM persistence models or imply an
|
||||||
|
Access permission. Consumers can retain source revisions and inclusion or
|
||||||
|
exclusion provenance while remaining optional-module safe.
|
||||||
|
|
||||||
|
Modules contribute reusable process baselines through
|
||||||
|
`ModuleManifest.workflow_definitions`. Each contribution pins its origin module
|
||||||
|
and version, stable key, schema and content hash, native graph/BPMN content,
|
||||||
|
governance ceilings, execution mode, and required capabilities/interfaces.
|
||||||
|
`govoplan-workflow-engine` reconciles these declarations idempotently. A module
|
||||||
|
upgrade appends a baseline revision without replacing the active revision or
|
||||||
|
mutating a local override; the optional `govoplan-workflow` package supplies
|
||||||
|
the comparison, derivation, and reset UI.
|
||||||
|
|
||||||
### Named Interface Contracts
|
### Named Interface Contracts
|
||||||
|
|
||||||
@@ -156,11 +272,52 @@ intended for SemVer major-version lines. Missing optional interfaces are
|
|||||||
allowed, but an installed provider with an incompatible version blocks
|
allowed, but an installed provider with an incompatible version blocks
|
||||||
activation because the integration would otherwise bind to an unsafe API.
|
activation because the integration would otherwise bind to an unsafe API.
|
||||||
|
|
||||||
|
### Source Authority And Provider Operations
|
||||||
|
|
||||||
|
Integration maturity and configured authority are independent. The existing
|
||||||
|
external-reference maturity ladder describes whether an adapter can discover,
|
||||||
|
link, search, read, publish, synchronize, migrate, or replace. A binding must
|
||||||
|
also state whether GovOPlaN is native authoritative, the external system is
|
||||||
|
authoritative, GovOPlaN keeps a mirror, both sides use governed sync, GovOPlaN
|
||||||
|
adds only a governance overlay, or the object is link-only.
|
||||||
|
|
||||||
|
`ModuleManifest.external_providers` composes existing contracts rather than
|
||||||
|
replacing them. Each declaration describes owned object/field groups, authority modes,
|
||||||
|
operations, revisions, freshness, health, limits, idempotency, conflicts,
|
||||||
|
outcome-unknown handling, evidence, correction/compensation, reconciliation,
|
||||||
|
outage behavior, classification, purpose, retention, and secret requirements.
|
||||||
|
Core owns the typed declaration and validation. Connectors and domain modules
|
||||||
|
own the actual protocol and domain behavior; configuration packages select the
|
||||||
|
effective mode and block missing/incompatible/unhealthy providers; Docs and Ops
|
||||||
|
explain the result. Effect-capable declarations fail validation unless their
|
||||||
|
retry, concurrency, outcome-unknown, correction, reconciliation, evidence,
|
||||||
|
audit, timeout, outage, classification, purpose, retention, and secret behavior
|
||||||
|
is explicit.
|
||||||
|
|
||||||
|
Declarations are release-time capability claims. Configured state is projected
|
||||||
|
separately through `ModuleManifest.external_provider_state_providers`. A state
|
||||||
|
provider receives a bounded tenant context and returns one sanitized observation
|
||||||
|
per configured binding: stable binding reference, effective authority mode,
|
||||||
|
active/configured state, health, freshness, conflict, recovery readiness,
|
||||||
|
observation/last-success time, and scalar metrics. Core validates and aggregates
|
||||||
|
those observations, isolates provider failures, and never accepts URLs,
|
||||||
|
credentials, or arbitrary nested metadata as runtime-state fields. Docs removes
|
||||||
|
binding-level detail from ordinary-user projections; Ops may show the full
|
||||||
|
sanitized operator projection.
|
||||||
|
|
||||||
|
Configuration-package preflight selects the exact requested binding from this
|
||||||
|
runtime state before evaluating authority, health, freshness, and recovery. A
|
||||||
|
healthy sibling binding therefore cannot mask an unhealthy required binding.
|
||||||
|
Providers with multiple configurations must use non-secret, stable references
|
||||||
|
such as `calendar:sync-source:<id>`.
|
||||||
|
|
||||||
Current named interfaces, generated from the source manifests by the workspace
|
Current named interfaces, generated from the source manifests by the workspace
|
||||||
contract checks, are:
|
contract checks, are:
|
||||||
|
|
||||||
- `addresses.contact_writer`, `addresses.lookup`, `addresses.recipient_source`
|
- `addresses.contact_point_resolution`, `addresses.contact_writer`,
|
||||||
- `calendar.outbox`, `calendar.scheduling`
|
`addresses.lookup`, `addresses.recipient_source`
|
||||||
|
- `calendar.external_profiles`, `calendar.invitations`, `calendar.outbox`,
|
||||||
|
`calendar.scheduling`
|
||||||
- `campaigns.access`, `campaigns.delivery_tasks`,
|
- `campaigns.access`, `campaigns.delivery_tasks`,
|
||||||
`campaigns.mail_policy_context`, `campaigns.policy_context`,
|
`campaigns.mail_policy_context`, `campaigns.policy_context`,
|
||||||
`campaigns.retention`
|
`campaigns.retention`
|
||||||
@@ -169,6 +326,8 @@ contract checks, are:
|
|||||||
- `files.access`, `files.campaign_attachments`
|
- `files.access`, `files.campaign_attachments`
|
||||||
- `mail.campaign_delivery`
|
- `mail.campaign_delivery`
|
||||||
- `notifications.dispatch`
|
- `notifications.dispatch`
|
||||||
|
- `application_status.projection`
|
||||||
|
- `payments.requests`
|
||||||
- `poll.availability_matrix`, `poll.option_selection`,
|
- `poll.availability_matrix`, `poll.option_selection`,
|
||||||
`poll.response_collection`, `poll.signed_participation`,
|
`poll.response_collection`, `poll.signed_participation`,
|
||||||
`poll.workflow_context`
|
`poll.workflow_context`
|
||||||
@@ -263,6 +422,23 @@ unsafe methods.
|
|||||||
This avoids retransmitting unchanged snapshots. It does not identify which row
|
This avoids retransmitting unchanged snapshots. It does not identify which row
|
||||||
changed inside a collection.
|
changed inside a collection.
|
||||||
|
|
||||||
|
### Mutation Preconditions
|
||||||
|
|
||||||
|
Weak response ETags are cache validators only. Mutable aggregates expose a
|
||||||
|
separate positive, monotonic revision and an opaque strong ETag generated by
|
||||||
|
`govoplan_core.core.concurrency.strong_resource_etag`. HTTP mutations send that
|
||||||
|
strong ETag in `If-Match`; capability and worker calls carry the equivalent
|
||||||
|
typed `expected_revision`.
|
||||||
|
|
||||||
|
Core's compare-and-set primitive advances the revision in the same transaction
|
||||||
|
as the domain mutation. A missing HTTP precondition is `428 Precondition
|
||||||
|
Required`, a stale HTTP precondition is `412 Precondition Failed`, and a
|
||||||
|
domain/reconciliation conflict is `409 Conflict`. Conflict responses contain
|
||||||
|
bounded resource and revision metadata rather than the complete current
|
||||||
|
object. Modules may opt into the conservative three-way merge helper, but must
|
||||||
|
declare protected workflow, delivery, ownership, lock, evidence, signature,
|
||||||
|
and cryptographic paths that can never be merged automatically.
|
||||||
|
|
||||||
### Delta Collections
|
### Delta Collections
|
||||||
|
|
||||||
Collection endpoints that can expose row-level changes should use the shared
|
Collection endpoints that can expose row-level changes should use the shared
|
||||||
@@ -333,6 +509,31 @@ full snapshot with `full: true`. A first-use `seq:0` watermark remains valid
|
|||||||
until such a floor exists, even if unrelated collections have advanced the
|
until such a floor exists, even if unrelated collections have advanced the
|
||||||
global sequence.
|
global sequence.
|
||||||
|
|
||||||
|
### Bounded Reference Selectors
|
||||||
|
|
||||||
|
Cross-module selectors use the module-neutral contract in
|
||||||
|
`govoplan_core.core.references`; consumers must not load an optional module's
|
||||||
|
complete directory and filter it in memory.
|
||||||
|
|
||||||
|
- Providers receive a normalized `ReferenceSearchRequest` with `kind`,
|
||||||
|
`tenant_id`, `query`, `selected_values`, `limit`, optional opaque `cursor`,
|
||||||
|
and policy context.
|
||||||
|
- Providers apply visibility and text filtering before materializing rows and
|
||||||
|
return `ReferenceSearchPage(options, next_cursor, has_more)`.
|
||||||
|
- A page contains at most the requested bounded search results. Already-selected
|
||||||
|
references are retained in addition to that bound so historical values remain
|
||||||
|
readable and removable even when they are inactive, deleted, or outside the
|
||||||
|
current search page.
|
||||||
|
- API consumers expose `next_cursor` and `has_more`. The current searchable
|
||||||
|
selector requests the first bounded page for each query; later load-more UI
|
||||||
|
can use the same cursor without changing the provider contract.
|
||||||
|
- `access.reference_options` supplies SQL-backed account, membership, and group
|
||||||
|
searches. When it is absent, Core degrades to the legacy Access directory or
|
||||||
|
to principal-only/unavailable references without importing Access.
|
||||||
|
- The shared WebUI `apiReferenceOptionProvider` resolves selected values in
|
||||||
|
chunks of at most 200, preventing a large existing selection from turning
|
||||||
|
into an unbounded request.
|
||||||
|
|
||||||
### Cursor/Keyset Pages
|
### Cursor/Keyset Pages
|
||||||
|
|
||||||
Offset pagination remains supported for compatibility and for first page loads,
|
Offset pagination remains supported for compatibility and for first page loads,
|
||||||
@@ -527,6 +728,46 @@ Rules:
|
|||||||
one migration run, and do not switch a database between tracks unless it is a
|
one migration run, and do not switch a database between tracks unless it is a
|
||||||
disposable development database.
|
disposable development database.
|
||||||
|
|
||||||
|
### Shared State And Runtime Ordering
|
||||||
|
|
||||||
|
Multi-host application roles use the `shared` state profile. In that profile,
|
||||||
|
PostgreSQL, Redis, a stable installation identifier, and S3-compatible object
|
||||||
|
storage are mandatory. Module durable artifacts must use Core's object-storage
|
||||||
|
contract and module-owned opaque key namespaces; node-local paths are limited
|
||||||
|
to temporary materialization. Same-host replicas may use the `host-shared`
|
||||||
|
profile and one shared volume.
|
||||||
|
|
||||||
|
Only the migration command mutates schema. PostgreSQL migration runs acquire a
|
||||||
|
deployment-wide advisory lock before module pre-tasks, Alembic, and post-tasks.
|
||||||
|
API, worker, and scheduler roles wait for exact configured migration heads and
|
||||||
|
fail closed instead of applying migrations during startup.
|
||||||
|
|
||||||
|
Runtime roles register identity, software/module composition, queues, heartbeat,
|
||||||
|
and drain state in PostgreSQL. Singleton work must use a distributed lease and
|
||||||
|
validate its monotonically increasing fencing token at the consequential
|
||||||
|
commit. See `STATE_AND_RECOVERY_CONTRACT.md` for the complete contract.
|
||||||
|
|
||||||
|
### Recovery Evidence
|
||||||
|
|
||||||
|
Operations spanning transactions, object storage, queues, or external systems
|
||||||
|
must choose an explicit Core recovery mode: atomic, compensation,
|
||||||
|
snapshot-restore, forward-recovery, or irreversible. Plans require verification
|
||||||
|
steps and mode-specific recovery material. Use idempotency keys, append-only
|
||||||
|
evidence checkpoints, and a runtime fence where work may race across nodes.
|
||||||
|
|
||||||
|
The recovery ledger is a shared primitive, not automatic coverage. A module may
|
||||||
|
claim its guarantees only after its operation records preconditions before side
|
||||||
|
effects, transitions partial/unknown outcomes honestly, and records verified
|
||||||
|
completion or recovery. Plaintext secrets must never enter recovery metadata or
|
||||||
|
evidence.
|
||||||
|
|
||||||
|
For a conclusive external result, modules may commit their local success
|
||||||
|
projection and the verified terminal checkpoint in one database transaction via
|
||||||
|
`DurableRecoveryOperation.commit_verified_success`. This does not make the
|
||||||
|
external provider effect atomic. It prevents a local `succeeded` state from
|
||||||
|
becoming authoritative when the recovery evidence chain is damaged or the
|
||||||
|
terminal checkpoint cannot commit.
|
||||||
|
|
||||||
## Install, Uninstall, And Catalogs
|
## Install, Uninstall, And Catalogs
|
||||||
|
|
||||||
Core owns the install plan, signed catalog validation, license entitlement
|
Core owns the install plan, signed catalog validation, license entitlement
|
||||||
@@ -585,11 +826,18 @@ Uninstall remains non-destructive unless the operator explicitly requests
|
|||||||
|
|
||||||
## WebUI Contract
|
## WebUI Contract
|
||||||
|
|
||||||
A WebUI module exports a `PlatformWebModule` from its package. The object contributes local/fallback metadata and route render functions.
|
A WebUI module exports a `PlatformWebModule` from its package. The object
|
||||||
|
contributes local/fallback metadata and route render functions. The package
|
||||||
|
must ship `src/module.ts` with the default contribution export: Core's Vite
|
||||||
|
host imports that descriptor directly after the backend reports the module as
|
||||||
|
enabled. This keeps package-root re-exports from pulling page implementations
|
||||||
|
into the initial shell.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
const FilesPage = lazy(() => import("./features/files/FilesPage"));
|
||||||
|
|
||||||
export const filesModule: PlatformWebModule = {
|
export const filesModule: PlatformWebModule = {
|
||||||
id: "files",
|
id: "files",
|
||||||
label: "Files",
|
label: "Files",
|
||||||
@@ -604,12 +852,39 @@ export const filesModule: PlatformWebModule = {
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Route pages and substantial panels must use stable lazy imports. Core supplies
|
||||||
|
the shared loading and retryable error state around route rendering. The
|
||||||
|
initial static import closure and largest asynchronous chunk are enforced by
|
||||||
|
the budgets documented in [WEBUI_BUNDLE_BUDGETS.md](WEBUI_BUNDLE_BUDGETS.md).
|
||||||
|
|
||||||
|
Every public platform interface has a stable declaration identity. Backend
|
||||||
|
routes, capabilities, interfaces, search providers/sources, permissions,
|
||||||
|
frontend routes/navigation, and View surfaces derive that identity from typed
|
||||||
|
`ModuleManifest` values. Typed WebUI capabilities declare IDs for settings,
|
||||||
|
admin sections, widgets, search contexts, and extension actions. Shared form
|
||||||
|
and action controls accept `interfaceId` and `helpTopicId`; use module-namespaced
|
||||||
|
values when another contract, documentation topic, or automated check must
|
||||||
|
refer to the control across source changes. The static inventory assigns a
|
||||||
|
line-independent source anchor when an explicit ID is absent and reports that
|
||||||
|
fact for later review.
|
||||||
|
|
||||||
|
Core exposes the sanitized runtime declaration set at
|
||||||
|
`GET /api/v1/platform/interface-catalog`. The endpoint is read-only, requires
|
||||||
|
`admin:module:read` or `system:settings:read`, and includes only modules
|
||||||
|
effective in the caller's active tenant context. It never serializes factories,
|
||||||
|
credentials, executable callbacks, or mutable module state. Registry validation
|
||||||
|
rejects conflicting declaration IDs before startup.
|
||||||
|
|
||||||
WebUI modules receive only the core route context:
|
WebUI modules receive only the core route context:
|
||||||
|
|
||||||
- `settings`
|
- `settings`
|
||||||
- `auth`
|
- `auth`
|
||||||
|
|
||||||
A module should call its own API client and module-owned backend routes. Shared API helpers should live in core only when they are truly platform-level concerns.
|
A module should call its own API client and module-owned backend routes. Shared API helpers should live in core only when they are truly platform-level concerns.
|
||||||
|
For ordinary JSON mutations, use Core's `apiPostJson` and `apiPatchJson`
|
||||||
|
helpers. They preserve the shared authentication, CSRF, error, and request
|
||||||
|
invalidation behavior while leaving endpoint types and feature semantics in the
|
||||||
|
owning module.
|
||||||
|
|
||||||
Modules can also contribute named UI capabilities for explicit extension
|
Modules can also contribute named UI capabilities for explicit extension
|
||||||
points. Capability values must be narrow, typed contracts, not imports from a
|
points. Capability values must be narrow, typed contracts, not imports from a
|
||||||
@@ -792,6 +1067,16 @@ Decision: templates and reporting are separate modules.
|
|||||||
and export targets
|
and export targets
|
||||||
- report permissions, report execution history, generated report evidence, and
|
- report permissions, report execution history, generated report evidence, and
|
||||||
report-specific retention inputs
|
report-specific retention inputs
|
||||||
|
|
||||||
|
Cross-module reports use Core's versioned
|
||||||
|
`reporting.report_provider.<provider-id>` contract. Source modules own
|
||||||
|
authorization, parameters, source revisions, effective scope, result schema,
|
||||||
|
and privacy transforms; Reporting owns discovery, validation, governed
|
||||||
|
execution, provenance, export history, and the global `/reports` route. The
|
||||||
|
optional `policy.reporting_governance` capability can only tighten execution,
|
||||||
|
retention, export, and re-identification-risk handling. Reporting exposes
|
||||||
|
`reporting.retention` so the Policy-owned retention run can minimize expired
|
||||||
|
provider results without importing Reporting models.
|
||||||
- downstream export handoff to files, dataflow, connectors, or publication
|
- downstream export handoff to files, dataflow, connectors, or publication
|
||||||
surfaces
|
surfaces
|
||||||
|
|
||||||
@@ -821,7 +1106,7 @@ First slice:
|
|||||||
- `govoplan-files` owns file-backed governed locations and uploaded/stored file
|
- `govoplan-files` owns file-backed governed locations and uploaded/stored file
|
||||||
evidence.
|
evidence.
|
||||||
- `govoplan-reporting` owns report/data views and scheduled outputs.
|
- `govoplan-reporting` owns report/data views and scheduled outputs.
|
||||||
- `govoplan-workflow` owns process state, approvals, scheduling of process
|
- `govoplan-workflow-engine` owns process state, approvals, scheduling of process
|
||||||
steps, and human review.
|
steps, and human review.
|
||||||
|
|
||||||
Future `govoplan-datasources` is justified when GovOPlaN needs a broad source
|
Future `govoplan-datasources` is justified when GovOPlaN needs a broad source
|
||||||
@@ -884,7 +1169,7 @@ from workflow semantics.
|
|||||||
- form definitions, schemas, validation rules, field visibility rules,
|
- form definitions, schemas, validation rules, field visibility rules,
|
||||||
localization, versioning, admin editing, and reusable form package fragments
|
localization, versioning, admin editing, and reusable form package fragments
|
||||||
|
|
||||||
`govoplan-forms-runtime` owns, when implemented:
|
`govoplan-forms-runtime` owns:
|
||||||
|
|
||||||
- public/internal submissions, drafts, submitted values, validation evidence,
|
- public/internal submissions, drafts, submitted values, validation evidence,
|
||||||
attachment references, submission receipts, and handoff events
|
attachment references, submission receipts, and handoff events
|
||||||
@@ -897,6 +1182,16 @@ Boundary:
|
|||||||
- Reporting/dataflow may consume submitted data through governed DTOs or
|
- Reporting/dataflow may consume submitted data through governed DTOs or
|
||||||
source lifecycle contracts.
|
source lifecycle contracts.
|
||||||
|
|
||||||
|
Implemented contract:
|
||||||
|
|
||||||
|
- Core owns the provider-neutral `FormDefinition`/`FormFieldDefinition` DTOs.
|
||||||
|
- Forms persists immutable exact definitions and provides `forms.definitions`.
|
||||||
|
- Forms Runtime resolves that capability, persists revisioned instances and
|
||||||
|
events, validates draft/final values, and provides
|
||||||
|
`forms_runtime.service_launcher`.
|
||||||
|
- Portal delegates exact `<form-id>/<revision>` bindings and never writes either
|
||||||
|
owner's tables.
|
||||||
|
|
||||||
### OpenDesk Integration Profile
|
### OpenDesk Integration Profile
|
||||||
|
|
||||||
Tracking: `govoplan-core#195`, `govoplan-connectors#5`,
|
Tracking: `govoplan-core#195`, `govoplan-connectors#5`,
|
||||||
@@ -985,6 +1280,61 @@ devserver, development bootstrap, background worker registry, and migration
|
|||||||
metadata plan all read the saved desired state from `system_settings` before
|
metadata plan all read the saved desired state from `system_settings` before
|
||||||
building their module registry.
|
building their module registry.
|
||||||
|
|
||||||
|
### Tenant entitlement and personal visibility
|
||||||
|
|
||||||
|
Deployment activation remains process-wide: one installed and active registry
|
||||||
|
is shared by every tenant served by that process. Tenant module selection is a
|
||||||
|
separate entitlement document in `core_scopes.settings.module_entitlements`:
|
||||||
|
|
||||||
|
- a system policy marks each installed module `unavailable`, `available`, or
|
||||||
|
`forced` for one tenant;
|
||||||
|
- the tenant selection may enable or disable only available modules;
|
||||||
|
- protected platform modules, forced modules, and transitive dependencies stay
|
||||||
|
effective;
|
||||||
|
- malformed explicit entitlement fails closed to protected modules, while an
|
||||||
|
absent document preserves the pre-entitlement behavior for upgraded tenants;
|
||||||
|
- an optimistic revision prevents concurrent system and tenant administrators
|
||||||
|
from silently replacing each other's changes.
|
||||||
|
|
||||||
|
The authenticated platform metadata and module route guard intersect global
|
||||||
|
runtime activation with the active tenant's effective entitlement. Entitlement
|
||||||
|
does not grant a permission. Access authorization must still allow every API
|
||||||
|
operation and resource.
|
||||||
|
|
||||||
|
The same boundary applies outside authenticated request handling:
|
||||||
|
|
||||||
|
- capability factories retain their owning module, and tenant-scoped capability
|
||||||
|
lookup treats a provider that is unavailable to the tenant as absent;
|
||||||
|
- workers partition scheduled scans by tenant before claiming rows;
|
||||||
|
- new work is rejected while a module is unavailable, while already accepted
|
||||||
|
durable work remains in provider-owned storage and is reported as
|
||||||
|
`operator_action_required` instead of being dropped or executed;
|
||||||
|
- Workflow, Dataflow, event consumers, reconciliation jobs, and external-effect
|
||||||
|
outboxes run inside a tenant execution context, so their optional capability
|
||||||
|
calls inherit the same provider checks;
|
||||||
|
- public signed-link modules declare a `public_tenant_resolver`; valid token
|
||||||
|
context is resolved before the route runs and the module entitlement is then
|
||||||
|
enforced without requiring an authenticated principal.
|
||||||
|
|
||||||
|
Entitlement resolution uses a bounded process-local cache. A local policy
|
||||||
|
mutation invalidates its tenant entry immediately; changes made by another node
|
||||||
|
become authoritative after `TENANT_MODULE_ENTITLEMENT_CACHE_TTL_SECONDS`
|
||||||
|
(five seconds by default). This is a bounded staleness optimization, not an
|
||||||
|
authorization grant: a cache miss or resolution failure fails closed.
|
||||||
|
|
||||||
|
Users and groups do not own another module-runtime state. Every WebUI module
|
||||||
|
already contributes a root `<module>.module` View surface, so personal and
|
||||||
|
group module visibility is expressed through Views. View policy controls who
|
||||||
|
may select, assign, edit, derive, or workflow-activate those projections;
|
||||||
|
required View assignments can retain required UI. Thus tenant entitlement owns
|
||||||
|
operational availability, Views own presentation, and Access owns authority.
|
||||||
|
|
||||||
|
Capability-style modules such as Encryption must keep activation separate from
|
||||||
|
domain data state. Making Encryption effective only exposes its capability and
|
||||||
|
administration surfaces. Encrypting, rekeying, decrypting, or migrating data is
|
||||||
|
an explicit versioned protection-policy operation owned by Encryption and the
|
||||||
|
module that owns the data.
|
||||||
|
|
||||||
Hot enable/disable is a core design principle for every module:
|
Hot enable/disable is a core design principle for every module:
|
||||||
|
|
||||||
- Core keeps one mutable active `PlatformRegistry` object and swaps its manifest
|
- Core keeps one mutable active `PlatformRegistry` object and swaps its manifest
|
||||||
@@ -1050,8 +1400,10 @@ The package install-plan API records operator intent only:
|
|||||||
- `GET /api/v1/admin/system/modules/package-catalog` reads approved package
|
- `GET /api/v1/admin/system/modules/package-catalog` reads approved package
|
||||||
references from `GOVOPLAN_MODULE_PACKAGE_CATALOG` so operators can add known
|
references from `GOVOPLAN_MODULE_PACKAGE_CATALOG` so operators can add known
|
||||||
module refs to the install plan without typing them manually. The endpoint
|
module refs to the install plan without typing them manually. The endpoint
|
||||||
also reports catalog validity, channel, signature, trust state, and the
|
also reports catalog validity, channel, signature, trust state, source and
|
||||||
configured path.
|
artifact provenance, release availability, configuration requirements, and
|
||||||
|
per-entry compatibility/blocker state. Withdrawn entries are visible for
|
||||||
|
diagnosis but cannot be planned.
|
||||||
- `POST /api/v1/admin/system/modules/install-plan/catalog/{module_id}` saves
|
- `POST /api/v1/admin/system/modules/install-plan/catalog/{module_id}` saves
|
||||||
a planned install or update row from a validated catalog entry. Installed
|
a planned install or update row from a validated catalog entry. Installed
|
||||||
modules are planned as updates. Catalog signature and approved-channel policy
|
modules are planned as updates. Catalog signature and approved-channel policy
|
||||||
@@ -1092,6 +1444,11 @@ The package install-plan API records operator intent only:
|
|||||||
default; successful uninstalls are removed from saved startup state by default.
|
default; successful uninstalls are removed from saved startup state by default.
|
||||||
Use `--no-activate-installed-modules` or
|
Use `--no-activate-installed-modules` or
|
||||||
`--keep-uninstalled-modules-in-desired` only for staged rollout workflows.
|
`--keep-uninstalled-modules-in-desired` only for staged rollout workflows.
|
||||||
|
- Every non-dry installer and live active-graph mutation acquires the
|
||||||
|
deployment-wide `core:module-lifecycle:deployment` lease and records a Core
|
||||||
|
recovery operation. Unresolved effects block later lifecycle changes. The
|
||||||
|
operation modes and operator reconciliation contract are defined in
|
||||||
|
`MODULE_LIFECYCLE_RECOVERY.md`.
|
||||||
- `govoplan-module-installer --supervise --migrate --health-url http://127.0.0.1:8000/health --restart-command '<restart govoplan server>'`
|
- `govoplan-module-installer --supervise --migrate --health-url http://127.0.0.1:8000/health --restart-command '<restart govoplan server>'`
|
||||||
is the preferred disruptive-change path. It applies the plan, optionally runs
|
is the preferred disruptive-change path. It applies the plan, optionally runs
|
||||||
migrations in a fresh Python process after a fresh-process manifest
|
migrations in a fresh Python process after a fresh-process manifest
|
||||||
@@ -1141,6 +1498,13 @@ the same restart/health set after restoring package and database snapshots.
|
|||||||
The installer preflight is intentionally conservative:
|
The installer preflight is intentionally conservative:
|
||||||
|
|
||||||
- maintenance mode must be active;
|
- maintenance mode must be active;
|
||||||
|
- the `shared` state profile blocks in-place package mutation; clustered
|
||||||
|
installations must roll one verified immutable module composition across all
|
||||||
|
replicas;
|
||||||
|
- official runtime images carry the full verified package profile, while the
|
||||||
|
desired module graph controls activation and tenant/View/Policy contracts
|
||||||
|
control availability and presentation; package lifecycle must not be reused
|
||||||
|
as a tenant or user visibility switch;
|
||||||
- installed module manifests must be compatible with the supported manifest
|
- installed module manifests must be compatible with the supported manifest
|
||||||
contract and current core version;
|
contract and current core version;
|
||||||
- uninstalling `tenancy`, `access`, or `admin` is blocked;
|
- uninstalling `tenancy`, `access`, or `admin` is blocked;
|
||||||
@@ -1241,6 +1605,33 @@ The first implementation is a platform access gate. It does not replace
|
|||||||
database backups, process supervision, migration checks, or external load
|
database backups, process supervision, migration checks, or external load
|
||||||
balancer maintenance pages.
|
balancer maintenance pages.
|
||||||
|
|
||||||
|
## Connector Runtime Contract
|
||||||
|
|
||||||
|
Core defines provider-neutral connector preview and diagnostic primitives in
|
||||||
|
`govoplan_core.core.connector_runtime`. The contract keeps optional modules
|
||||||
|
decoupled: Connectors owns transport, endpoint discovery, retries, and protocol
|
||||||
|
health; the consuming domain module owns mappings, validation, reconciliation,
|
||||||
|
and mutations of its records.
|
||||||
|
|
||||||
|
Every dry run is bounded and identifies the source revision, source fingerprint,
|
||||||
|
immutable input hash, effects, and redacted diagnostics. Its summary must match
|
||||||
|
the returned effect list exactly. An apply token is usable only when the preview
|
||||||
|
is complete, current, conflict-free, and contains no error diagnostic. Endpoint
|
||||||
|
URLs never contain credentials; only credential-envelope references cross the
|
||||||
|
contract. Provider-specific details belong in sanitized provenance rather than
|
||||||
|
in a shared domain schema.
|
||||||
|
|
||||||
|
## Semantic Documentation Subject Contract
|
||||||
|
|
||||||
|
Optional modules expose configured artifacts that can be documented through
|
||||||
|
the module-scoped `documentation.semantic_subjects.<module_id>` capability.
|
||||||
|
Core supplies stable tenant-scoped references, typed nested anchors, safe
|
||||||
|
localized descriptors, revision/fingerprint review signals, and explicit
|
||||||
|
availability states. Providers remain responsible for authorization and do not
|
||||||
|
expose configuration payloads or credentials. Docs discovers the capability
|
||||||
|
and owns authored content; it does not import feature internals. See
|
||||||
|
`SEMANTIC_DOCUMENTATION_SUBJECTS.md` for the contract and adoption rules.
|
||||||
|
|
||||||
## Build And Verification
|
## Build And Verification
|
||||||
|
|
||||||
Backend verification from core:
|
Backend verification from core:
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -13,6 +13,8 @@ consistent while each module still owns its domain rules.
|
|||||||
| RBAC/access policy | `govoplan-access` | access capabilities in `govoplan_core.core.access` | Permission decisions should use access capability contracts. Explain responses should adopt `PolicyDecision` when an API-level explanation is added. |
|
| 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. |
|
| 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. |
|
| 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
|
## Policy Decision
|
||||||
|
|
||||||
@@ -111,6 +113,57 @@ matching compatibility DTOs only for its legacy admin surface. Admin overview
|
|||||||
responses remain module-local because the same counters are exposed from
|
responses remain module-local because the same counters are exposed from
|
||||||
different menu contexts and are not yet a separately versioned platform API.
|
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
|
## Frontend Contract
|
||||||
|
|
||||||
Policy UIs must:
|
Policy UIs must:
|
||||||
@@ -123,6 +176,9 @@ Policy UIs must:
|
|||||||
lower-level limit to `false`
|
lower-level limit to `false`
|
||||||
- avoid sending locked fields or re-enable attempts in save payloads
|
- avoid sending locked fields or re-enable attempts in save payloads
|
||||||
- show inherited values separately from local overrides
|
- 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
|
The core WebUI helper `privacyRetentionParentAllowsField()` centralizes the
|
||||||
field-lock decision used by the retention editor and its lightweight module
|
field-lock decision used by the retention editor and its lightweight module
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
# Postbox End-To-End Encryption Architecture
|
# Postbox End-To-End Encryption Architecture
|
||||||
|
|
||||||
This document records the strategic encryption target for GovOPlaN postboxes.
|
This document records the encryption boundary for GovOPlaN postboxes. Postbox
|
||||||
It does not require the first postbox implementation to ship full E2EE, but it
|
now implements the server-side contracts for three selectable profiles:
|
||||||
defines the architecture so early data models and APIs do not make the stronger
|
unencrypted content, institution-managed server envelopes, and externally
|
||||||
model impossible.
|
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
|
The core principle is that a postbox can become a trusted administrative
|
||||||
communication channel without requiring the server to see plaintext content.
|
communication channel without requiring the server to see plaintext content.
|
||||||
@@ -35,6 +38,54 @@ Algorithm choices should remain replaceable behind a crypto profile. The first
|
|||||||
profile should prefer standard, reviewed primitives such as HPKE for key
|
profile should prefer standard, reviewed primitives such as HPKE for key
|
||||||
wrapping and AEAD encryption for content.
|
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
|
## Identity And Device Keys
|
||||||
|
|
||||||
The platform should distinguish:
|
The platform should distinguish:
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ before deciding to replace specialist workflows. This document is the core
|
|||||||
strategy index. The executable connector catalogue lives in
|
strategy index. The executable connector catalogue lives in
|
||||||
`govoplan-connectors/docs/PUBLIC_SECTOR_INTEGRATION_CATALOGUE.md`.
|
`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
|
## Strategy Labels
|
||||||
|
|
||||||
Use one or more of these labels for every external system family:
|
Use one or more of these labels for every external system family:
|
||||||
@@ -138,6 +141,73 @@ connector or module issue.
|
|||||||
- Owner/priority: `govoplan-mail`, `govoplan-calendar`,
|
- Owner/priority: `govoplan-mail`, `govoplan-calendar`,
|
||||||
`govoplan-connectors`, Wave 1/2.
|
`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
|
### Payment And Public Cashier Systems
|
||||||
|
|
||||||
- Strategy: integrate/export/import; keep the payment provider or cashier as
|
- Strategy: integrate/export/import; keep the payment provider or cashier as
|
||||||
@@ -172,8 +242,10 @@ connector or module issue.
|
|||||||
queries, untraceable manual transformations.
|
queries, untraceable manual transformations.
|
||||||
- MVP test path: publish one report/export as a governed file plus RSS/Atom
|
- MVP test path: publish one report/export as a governed file plus RSS/Atom
|
||||||
entry with checksum, timestamp, and permission check.
|
entry with checksum, timestamp, and permission check.
|
||||||
- Owner/priority: `govoplan-reporting`, `govoplan-connectors`, possible future
|
- Owner/priority: `govoplan-reporting`, `govoplan-connectors`,
|
||||||
`govoplan-datasources`/`govoplan-dataflow`, Wave 2.
|
`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
|
### Public-Sector Protocols And Registries
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -40,26 +40,26 @@ cd /mnt/DATA/git/govoplan
|
|||||||
Update those refs when cutting a release:
|
Update those refs when cutting a release:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
govoplan-tenancy git@git.add-ideas.de:add-ideas/govoplan-tenancy.git v0.1.8
|
govoplan-tenancy git@git.add-ideas.de:GovOPlaN/govoplan-tenancy.git v0.1.8
|
||||||
govoplan-organizations git@git.add-ideas.de:add-ideas/govoplan-organizations.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:add-ideas/govoplan-identity.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:add-ideas/govoplan-idm.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:add-ideas/govoplan-access.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:add-ideas/govoplan-admin.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:add-ideas/govoplan-policy.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:add-ideas/govoplan-audit.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:add-ideas/govoplan-dashboard.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:add-ideas/govoplan-addresses.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:add-ideas/govoplan-files.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:add-ideas/govoplan-mail.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:add-ideas/govoplan-campaign.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:add-ideas/govoplan-calendar.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:add-ideas/govoplan-poll.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:add-ideas/govoplan-scheduling.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:add-ideas/govoplan-notifications.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:add-ideas/govoplan-evaluation.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:add-ideas/govoplan-docs.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:add-ideas/govoplan-ops.git v0.1.8
|
govoplan-ops git@git.add-ideas.de:GovOPlaN/govoplan-ops.git v0.1.8
|
||||||
```
|
```
|
||||||
|
|
||||||
## WebUI Packages
|
## WebUI Packages
|
||||||
@@ -151,7 +151,8 @@ Current tag-only module repositories:
|
|||||||
- `govoplan-search`
|
- `govoplan-search`
|
||||||
- `govoplan-tasks`
|
- `govoplan-tasks`
|
||||||
- `govoplan-templates`
|
- `govoplan-templates`
|
||||||
- `govoplan-workflow`
|
- `govoplan-workflow-engine` (headless definitions, migrations, and execution)
|
||||||
|
- `govoplan-workflow` (optional authoring and inspection WebUI)
|
||||||
- `govoplan-xoev`
|
- `govoplan-xoev`
|
||||||
- `govoplan-xrechnung`
|
- `govoplan-xrechnung`
|
||||||
- `govoplan-xta-osci`
|
- `govoplan-xta-osci`
|
||||||
@@ -196,6 +197,13 @@ If both file and URL are set, the URL wins. The cache is used when a remote
|
|||||||
fetch fails, so an operator can still inspect the last known catalog. A cached
|
fetch fails, so an operator can still inspect the last known catalog. A cached
|
||||||
catalog must still pass signature, freshness, channel, and replay validation.
|
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:
|
An official catalog is a JSON object with:
|
||||||
|
|
||||||
- `catalog_version`
|
- `catalog_version`
|
||||||
@@ -211,6 +219,14 @@ Each module entry can declare:
|
|||||||
|
|
||||||
- backend package name and pinned install reference
|
- backend package name and pinned install reference
|
||||||
- WebUI 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
|
- display metadata and tags
|
||||||
- `license_features`, the feature entitlements required to plan that install
|
- `license_features`, the feature entitlements required to plan that install
|
||||||
- `dependencies` and `optional_dependencies`, the module ids expected in the
|
- `dependencies` and `optional_dependencies`, the module ids expected in the
|
||||||
@@ -238,6 +254,12 @@ Each module entry can declare:
|
|||||||
- `requires_interfaces`, named interface contracts and version ranges required
|
- `requires_interfaces`, named interface contracts and version ranges required
|
||||||
by this module
|
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
|
The signature is Ed25519 over canonical JSON with both `signature` and
|
||||||
`signatures` removed. Core accepts the legacy single `signature` field and the
|
`signatures` removed. Core accepts the legacy single `signature` field and the
|
||||||
new `signatures` array.
|
new `signatures` array.
|
||||||
@@ -301,6 +323,12 @@ Catalog provenance changes preflight severity:
|
|||||||
plans, so operators can still use offline or emergency package refs
|
plans, so operators can still use offline or emergency package refs
|
||||||
- valid-catalog warnings, such as intentionally unsigned local catalogs when
|
- valid-catalog warnings, such as intentionally unsigned local catalogs when
|
||||||
signature enforcement is disabled, remain warnings
|
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
|
- selected catalog entries with unsatisfied non-optional named interface ranges
|
||||||
block activation before the installer runs
|
block activation before the installer runs
|
||||||
- selected catalog entries whose target dependencies are neither installed nor
|
- selected catalog entries whose target dependencies are neither installed nor
|
||||||
@@ -518,6 +546,11 @@ Catalog entries can require license features:
|
|||||||
Core checks those requirements against an offline license file before allowing
|
Core checks those requirements against an offline license file before allowing
|
||||||
the entry into the install plan.
|
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
|
```bash
|
||||||
GOVOPLAN_LICENSE_FILE=/srv/govoplan/license.json
|
GOVOPLAN_LICENSE_FILE=/srv/govoplan/license.json
|
||||||
GOVOPLAN_LICENSE_ENFORCEMENT=true
|
GOVOPLAN_LICENSE_ENFORCEMENT=true
|
||||||
@@ -788,10 +821,27 @@ tools/checks/postgres-integration-check.py \
|
|||||||
The script checks migrations and `/health` startup for core-only, files-only,
|
The script checks migrations and `/health` startup for core-only, files-only,
|
||||||
mail-only, campaign-only, campaign+files, campaign+mail, and full-product
|
mail-only, campaign-only, campaign+files, campaign+mail, and full-product
|
||||||
module sets. `--reset-schema` is destructive and must only be used against a
|
module sets. `--reset-schema` is destructive and must only be used against a
|
||||||
throwaway database.
|
throwaway database. Before those permutations, the required Core proof runs in
|
||||||
|
random, test-owned schemas without modifying `public`. It exercises Files' real
|
||||||
|
credential-owning retirement provider and proves that credential scrubbing,
|
||||||
|
non-secret audit
|
||||||
|
insertion, and table retirement commit together; database-injected audit and
|
||||||
|
DDL failures roll the entire unit back. A 500 ms PostgreSQL `lock_timeout` and
|
||||||
|
captured backend process IDs also prove that each `DROP TABLE` uses the
|
||||||
|
installer Session connection instead of waiting through a second connection.
|
||||||
|
The meta check enables the release-gate flag so missing PostgreSQL configuration
|
||||||
|
or full-stack test packages are a hard failure; ordinary Core-only test discovery
|
||||||
|
skips this integration proof. Do not pass `--skip-retirement-atomicity` when
|
||||||
|
collecting release evidence.
|
||||||
|
|
||||||
## Migration Baselines
|
## 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.
|
Development migrations may be small and numerous while a feature is moving.
|
||||||
GovOPlaN keeps those detailed migrations on an explicit development track and
|
GovOPlaN keeps those detailed migrations on an explicit development track and
|
||||||
publishes reviewed release shortcuts on the release track. Before a stable
|
publishes reviewed release shortcuts on the release track. Before a stable
|
||||||
@@ -881,7 +931,7 @@ before that baseline, so pre-v0.1.7 development revisions are not release
|
|||||||
upgrade targets. Future release-to-release changes must start from a recorded
|
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 baseline and add a new release-track step-up instead of replacing prior
|
||||||
release shortcuts. The tracking issue is
|
release shortcuts. The tracking issue is
|
||||||
`add-ideas/govoplan-core#223`.
|
`GovOPlaN/govoplan-core#223`.
|
||||||
|
|
||||||
## Related Operator Documents
|
## Related Operator Documents
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -12,4 +12,4 @@ tools/checks/security-audit/run.sh --mode full --scope govoplan
|
|||||||
|
|
||||||
Canonical documentation:
|
Canonical documentation:
|
||||||
|
|
||||||
- `/mnt/DATA/git/govoplan/docs/SECURITY_AUDIT.md`
|
- `/mnt/DATA/git/govoplan/docs/operations/SECURITY_AUDIT.md`
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
+7
-4
@@ -3,10 +3,13 @@
|
|||||||
Core exposes `govoplan_core.core.throttling` for security-sensitive endpoints
|
Core exposes `govoplan_core.core.throttling` for security-sensitive endpoints
|
||||||
that need bounded fixed-window counters. Subjects are SHA-256 hashed before they
|
that need bounded fixed-window counters. Subjects are SHA-256 hashed before they
|
||||||
become store keys. A configured Redis instance provides atomic counters shared
|
become store keys. A configured Redis instance provides atomic counters shared
|
||||||
across API workers; development, a missing Redis configuration, and temporary
|
across API workers. Development and temporary Redis outages use a bounded
|
||||||
Redis outages use a bounded process-local fallback. When Redis fails, local
|
process-local fallback. Production-like startup rejects an enabled login
|
||||||
attempts are still mirrored so losing the distributed store does not reset the
|
throttle without `REDIS_URL` unless
|
||||||
active worker's protection window.
|
`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
|
Callers define one or more `ThrottleDimension` values with a controlled
|
||||||
namespace, a subject and a positive limit. They must call `check` before an
|
namespace, a subject and a positive limit. They must call `check` before an
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -6,7 +6,7 @@ binding design reference: future implementation should follow these decisions
|
|||||||
unless the decision is explicitly revised here and affected screens are updated
|
unless the decision is explicitly revised here and affected screens are updated
|
||||||
to match.
|
to match.
|
||||||
|
|
||||||
Active tracking issue: `add-ideas/govoplan-core#225`.
|
Active tracking issue: `GovOPlaN/govoplan-core#225`.
|
||||||
|
|
||||||
## Operating Rule
|
## Operating Rule
|
||||||
|
|
||||||
@@ -50,6 +50,15 @@ contestability, responsibility, and traceability at the point of action.
|
|||||||
| UX-024 | Explicit `Discard` actions and dirty in-application navigation use the shared `UnsavedChangesProvider` dialog. A page registers save/discard behavior with `useUnsavedDraftGuard`; its Discard button calls `requestDiscard`, and route changes use `useGuardedNavigate` or `requestNavigation`. | Accepted | All create/edit surfaces |
|
| UX-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-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-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
|
## Confirmed Implementation Decisions
|
||||||
|
|
||||||
@@ -156,14 +165,19 @@ Decision: the WebUI shell exposes a small, stable appearance contract based on
|
|||||||
shared CSS tokens and persisted user preference selection.
|
shared CSS tokens and persisted user preference selection.
|
||||||
|
|
||||||
- Core applies `system`, `light`, and `dark` preferences at the document root.
|
- 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`,
|
- Core owns shared tokens such as `--bg`, `--bar`, `--panel`, `--surface`,
|
||||||
`--line`, `--line-dark`, `--text`, `--text-strong`, `--muted`, semantic
|
`--line`, `--line-dark`, `--text`, `--text-strong`, `--muted`, semantic
|
||||||
status colors, radii, shadows, and disabled-control colors.
|
status colors, radii, shadows, and disabled-control colors.
|
||||||
- Modules must style new UI with these tokens and shared controls. Module-local
|
- 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
|
CSS may tune layout and spacing, but it must not introduce a separate
|
||||||
appearance system.
|
appearance system.
|
||||||
- Appearance controls live in user settings first. Tenant defaults and policy
|
- Appearance controls live in user settings. A personal palette wins over
|
||||||
enforcement can be added later without changing the token contract.
|
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,
|
- Visual preview in settings is illustrative; it must reflect token families,
|
||||||
not become a second theme implementation.
|
not become a second theme implementation.
|
||||||
|
|
||||||
@@ -223,6 +237,10 @@ instead of reproducing their behavior.
|
|||||||
- `help` content is contextual guidance, not the accessible name. The persisted
|
- `help` content is contextual guidance, not the accessible name. The persisted
|
||||||
`show_inline_help_hints` user preference hides only the `InlineHelp` marker by
|
`show_inline_help_hints` user preference hides only the `InlineHelp` marker by
|
||||||
applying `ui-hide-help-hints` at the document root.
|
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
|
- A dirty editor registers once with `useUnsavedDraftGuard`. An explicit
|
||||||
Discard button calls `useUnsavedChanges().requestDiscard(afterResolve)`; SPA
|
Discard button calls `useUnsavedChanges().requestDiscard(afterResolve)`; SPA
|
||||||
navigation uses `useGuardedNavigate` or `requestNavigation`. Both paths show
|
navigation uses `useGuardedNavigate` or `requestNavigation`. Both paths show
|
||||||
@@ -245,6 +263,25 @@ instead of reproducing their behavior.
|
|||||||
- Feedback and confirmation use `Dialog`, `ConfirmDialog`, or
|
- Feedback and confirmation use `Dialog`, `ConfirmDialog`, or
|
||||||
`DismissibleAlert`. They never fall back to `window.alert`.
|
`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
|
#### FieldLabel Omission Register
|
||||||
|
|
||||||
Every Core field surface that intentionally does not render `FieldLabel` is
|
Every Core field surface that intentionally does not render `FieldLabel` is
|
||||||
@@ -253,7 +290,7 @@ UI documentation until a central cross-repository audit is available.
|
|||||||
|
|
||||||
| Core scope | Why `FieldLabel` is omitted | Accessible/context label source |
|
| Core scope | Why `FieldLabel` is omitted | Accessible/context label source |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `PasswordField`, `ColorPickerField`, `DateField`, `TimeField`, and `DateTimeField` input internals | These are label-neutral composite primitives and are placed inside `FormField`/`FieldLabel` by the consuming form. Rendering another label inside the primitive would duplicate it. | Enclosing label; a direct consumer must pass an accessible name and record that direct composition here. |
|
| `PasswordField`, `ColorPickerField`, `DateField`, `TimeField`, and `DateTimeField` input internals | These are label-neutral composite primitives and are placed inside `FormField`/`FieldLabel` by the consuming form. Rendering another label inside the primitive would duplicate it. `PasswordField` may opt into the shared cryptographic generator; the candidate dialog is subordinate to the enclosing field and commits only through its explicit Use action. | Enclosing label; a direct consumer must pass an accessible name and record that direct composition here. |
|
||||||
| `ToggleSwitch` native checkbox | The shared component already renders its visible text through `FieldLabel`; the native input must not render a second label. | The enclosing native label and derived `aria-label`. |
|
| `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`. |
|
| `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. |
|
| `AdminSelectionList` and `DataGrid` list-filter checkboxes | Each option is self-explanatory and already enclosed by its visible option label. | Enclosing native option label. |
|
||||||
@@ -284,14 +321,14 @@ converted or reviewed.
|
|||||||
|
|
||||||
| Surface | Repository | UX State | Next Action |
|
| Surface | Repository | UX State | Next Action |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| File connector settings | `govoplan-files` | First adaptive modal slice started: connections and credentials now use full-state create/edit forms with conditional fields, advanced panels, and blocker primitives. Wizard shell is retained for later assisted setup. Central policy card still needs a layered editor. | Finish provider discovery/test-in-flow, then convert policy editing. |
|
| 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` | Uses the shared server/credential model visually, but create/edit still needs the same adaptive pattern as files. | Migrate to adaptive server/credential/policy dialogs, with optional assisted wizard later. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| Configuration packages | `govoplan-admin` | Catalog/import work exists, but package editing can still drift toward technical fields. | Add guided import/review/problem-list flow. |
|
||||||
| Retention and privacy | `govoplan-core` | Functional editor exists; consequence language and provenance can be stronger. | Layer advanced retention options and add review for broad changes. |
|
| Retention and privacy | `govoplan-core` | Typed effective-policy editor exposes source paths, narrowing semantics, platform locks, permission/target blockers, and explicit clean/loading/save states. | Broader governed-change review remains module-owned where a policy change requires approval. |
|
||||||
| API keys | `govoplan-access` / admin UI | Security-sensitive creation needs least-privilege guidance. | Add scoped creation wizard with expiry/owner review. |
|
| API keys | `govoplan-access` / admin UI | Security-sensitive creation needs least-privilege guidance. | Add scoped creation wizard with expiry/owner review. |
|
||||||
| User settings | `govoplan-core` | Preferences persistence exists; interface navigation issue was fixed earlier, but the surface still needs UX review. | Keep simple sections, remove double-click traps, and add quiet explanations. |
|
| User settings | `govoplan-core` | Simple typed sections use unsaved-change guards, quiet result feedback, contextual help, explicit busy/clean disabled-action reasons, and an effective appearance source. Palette selection and light/dark preview are shared with system and tenant administration. | Keep bounded; new contributed sections must satisfy the checklist. |
|
||||||
|
|
||||||
## Impact Index
|
## Impact Index
|
||||||
|
|
||||||
@@ -307,7 +344,7 @@ converted or reviewed.
|
|||||||
| Automation/workflow commands | Hidden side effects would undermine accountability. | Action/effect preview, system-actor display, command record, retry/quarantine/manual states, and audit links. |
|
| 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. |
|
| 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. |
|
| API keys | Security-sensitive creation and scope selection. | Scoped creation wizard, least-privilege suggestions, clear expiry/owner explanation. |
|
||||||
| User settings | Needs clarity and persistence across profile/interface/preferences. | Simple settings sections with immediate feedback and no double-click navigation traps. |
|
| User settings | Needs clarity and persistence across profile/interface/preferences. | Simple settings sections with immediate feedback, explicit inherit/reset semantics, effective-source provenance, and no double-click navigation traps. |
|
||||||
|
|
||||||
## Review Checklist
|
## Review Checklist
|
||||||
|
|
||||||
@@ -320,6 +357,11 @@ Every new or changed admin/configuration surface should answer:
|
|||||||
- Does the screen explain disabled actions and failed validation in plain
|
- Does the screen explain disabled actions and failed validation in plain
|
||||||
language?
|
language?
|
||||||
- Does it say who can fix a blocker and where?
|
- 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,
|
- Does it reuse existing core patterns for wizard steps, problem lists, modals,
|
||||||
help, and review?
|
help, and review?
|
||||||
- Is there a review or preflight step before broad, destructive, or risky
|
- Is there a review or preflight step before broad, destructive, or risky
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"description": "Portal form, case workflow, task creation, mail notification, payment setup, and access roles for a simple application process.",
|
"description": "Portal form, case workflow, task creation, mail notification, payment setup, and access roles for a simple application process.",
|
||||||
"publisher": "ADD ideas",
|
"publisher": "ADD ideas",
|
||||||
"category": "workflow",
|
"category": "workflow",
|
||||||
"artifact_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-configuration-packages.git@application-handling-basic-v0.1.0",
|
"artifact_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-configuration-packages.git@application-handling-basic-v0.1.0",
|
||||||
"artifact_sha256": "<sha256>",
|
"artifact_sha256": "<sha256>",
|
||||||
"required_modules": [
|
"required_modules": [
|
||||||
{ "module_id": "portal", "version": ">=0.1.0" },
|
{ "module_id": "portal", "version": ">=0.1.0" },
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -12,9 +12,9 @@
|
|||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"action": "install",
|
"action": "install",
|
||||||
"python_package": "govoplan-files",
|
"python_package": "govoplan-files",
|
||||||
"python_ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.4",
|
"python_ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.4",
|
||||||
"webui_package": "@govoplan/files-webui",
|
"webui_package": "@govoplan/files-webui",
|
||||||
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4",
|
"webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.4",
|
||||||
"migration_safety": "forward_only",
|
"migration_safety": "forward_only",
|
||||||
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
|
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
|
||||||
"migration_after": ["access"],
|
"migration_after": ["access"],
|
||||||
@@ -54,14 +54,14 @@
|
|||||||
],
|
],
|
||||||
"artifact_integrity": {
|
"artifact_integrity": {
|
||||||
"python": {
|
"python": {
|
||||||
"ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.4",
|
"ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.4",
|
||||||
"sha256": "<sha256 of the resolved Python artifact>",
|
"sha256": "<sha256 of the resolved Python artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-0.1.4.spdx.json",
|
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-0.1.4.spdx.json",
|
||||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-0.1.4.intoto.jsonl",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-0.1.4.intoto.jsonl",
|
||||||
"git_ref": "refs/tags/v0.1.4"
|
"git_ref": "refs/tags/v0.1.4"
|
||||||
},
|
},
|
||||||
"webui": {
|
"webui": {
|
||||||
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4",
|
"ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.4",
|
||||||
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-webui-0.1.4.spdx.json",
|
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-webui-0.1.4.spdx.json",
|
||||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-webui-0.1.4.intoto.jsonl",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-webui-0.1.4.intoto.jsonl",
|
||||||
@@ -78,9 +78,9 @@
|
|||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"action": "install",
|
"action": "install",
|
||||||
"python_package": "govoplan-mail",
|
"python_package": "govoplan-mail",
|
||||||
"python_ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.4",
|
"python_ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.4",
|
||||||
"webui_package": "@govoplan/mail-webui",
|
"webui_package": "@govoplan/mail-webui",
|
||||||
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4",
|
"webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.4",
|
||||||
"provides_interfaces": [
|
"provides_interfaces": [
|
||||||
{
|
{
|
||||||
"name": "mail.campaign_delivery",
|
"name": "mail.campaign_delivery",
|
||||||
@@ -97,14 +97,14 @@
|
|||||||
],
|
],
|
||||||
"artifact_integrity": {
|
"artifact_integrity": {
|
||||||
"python": {
|
"python": {
|
||||||
"ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.4",
|
"ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.4",
|
||||||
"sha256": "<sha256 of the resolved Python artifact>",
|
"sha256": "<sha256 of the resolved Python artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-0.1.4.spdx.json",
|
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-0.1.4.spdx.json",
|
||||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-0.1.4.intoto.jsonl",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-0.1.4.intoto.jsonl",
|
||||||
"git_ref": "refs/tags/v0.1.4"
|
"git_ref": "refs/tags/v0.1.4"
|
||||||
},
|
},
|
||||||
"webui": {
|
"webui": {
|
||||||
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4",
|
"ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.4",
|
||||||
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-webui-0.1.4.spdx.json",
|
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-webui-0.1.4.spdx.json",
|
||||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-webui-0.1.4.intoto.jsonl",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-webui-0.1.4.intoto.jsonl",
|
||||||
@@ -121,19 +121,19 @@
|
|||||||
"version": "0.1.6",
|
"version": "0.1.6",
|
||||||
"action": "install",
|
"action": "install",
|
||||||
"python_package": "govoplan-dashboard",
|
"python_package": "govoplan-dashboard",
|
||||||
"python_ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git@v0.1.6",
|
"python_ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.6",
|
||||||
"webui_package": "@govoplan/dashboard-webui",
|
"webui_package": "@govoplan/dashboard-webui",
|
||||||
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.6",
|
"webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.6",
|
||||||
"artifact_integrity": {
|
"artifact_integrity": {
|
||||||
"python": {
|
"python": {
|
||||||
"ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git@v0.1.6",
|
"ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.6",
|
||||||
"sha256": "<sha256 of the resolved Python artifact>",
|
"sha256": "<sha256 of the resolved Python artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-0.1.6.spdx.json",
|
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-0.1.6.spdx.json",
|
||||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-0.1.6.intoto.jsonl",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-0.1.6.intoto.jsonl",
|
||||||
"git_ref": "refs/tags/v0.1.6"
|
"git_ref": "refs/tags/v0.1.6"
|
||||||
},
|
},
|
||||||
"webui": {
|
"webui": {
|
||||||
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.6",
|
"ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.6",
|
||||||
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-webui-0.1.6.spdx.json",
|
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-webui-0.1.6.spdx.json",
|
||||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-webui-0.1.6.intoto.jsonl",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-webui-0.1.6.intoto.jsonl",
|
||||||
|
|||||||
+11
-3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-core"
|
name = "govoplan-core"
|
||||||
version = "0.1.12"
|
version = "0.1.34"
|
||||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
@@ -15,21 +15,29 @@ dependencies = [
|
|||||||
"fastapi>=0.139,<1",
|
"fastapi>=0.139,<1",
|
||||||
"pydantic>=2,<3",
|
"pydantic>=2,<3",
|
||||||
"pydantic-settings>=2,<3",
|
"pydantic-settings>=2,<3",
|
||||||
"cryptography>=48.0.1,<50",
|
"cryptography>=50.0.0,<51",
|
||||||
"celery>=5,<6",
|
"celery>=5,<6",
|
||||||
"redis>=5,<6",
|
"redis>=5,<6",
|
||||||
"alembic>=1,<2",
|
"alembic>=1,<2",
|
||||||
|
"boto3>=1.34,<2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
govoplan_core = ["py.typed"]
|
govoplan_core = ["py.typed", "resources/*.json"]
|
||||||
|
|
||||||
|
[tool.setuptools.data-files]
|
||||||
|
"govoplan_core_runtime" = ["alembic.ini"]
|
||||||
|
"govoplan_core_runtime/alembic" = ["alembic/env.py", "alembic/script.py.mako"]
|
||||||
|
"govoplan_core_runtime/alembic/versions" = ["alembic/versions/*.py"]
|
||||||
|
"govoplan_core_runtime/alembic/dev_versions" = ["alembic/dev_versions/*.py"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
govoplan-config = "govoplan_core.commands.config:main"
|
govoplan-config = "govoplan_core.commands.config:main"
|
||||||
govoplan-devserver = "govoplan_core.devserver:main"
|
govoplan-devserver = "govoplan_core.devserver:main"
|
||||||
|
govoplan-first-admin = "govoplan_core.commands.first_admin:main"
|
||||||
govoplan-module-install-plan = "govoplan_core.commands.module_install_plan:main"
|
govoplan-module-install-plan = "govoplan_core.commands.module_install_plan:main"
|
||||||
govoplan-module-installer = "govoplan_core.commands.module_installer:main"
|
govoplan-module-installer = "govoplan_core.commands.module_installer:main"
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class SystemSettings(Base, TimestampMixin):
|
|||||||
__tablename__ = "core_system_settings"
|
__tablename__ = "core_system_settings"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default="global")
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default="global")
|
||||||
default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False)
|
default_locale: Mapped[str] = mapped_column(String(20), default="de", nullable=False)
|
||||||
allow_tenant_custom_groups: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
allow_tenant_custom_groups: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
allow_tenant_custom_roles: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
allow_tenant_custom_roles: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
allow_tenant_api_keys: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
allow_tenant_api_keys: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
@@ -20,4 +20,3 @@ class SystemSettings(Base, TimestampMixin):
|
|||||||
|
|
||||||
|
|
||||||
__all__ = ["SystemSettings"]
|
__all__ = ["SystemSettings"]
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
from govoplan_core.core.appearance import normalize_appearance_overrides
|
||||||
|
|
||||||
|
|
||||||
class AuditLogItemResponse(BaseModel):
|
class AuditLogItemResponse(BaseModel):
|
||||||
@@ -39,6 +41,24 @@ class DeltaCollectionResponse(BaseModel):
|
|||||||
full: bool = False
|
full: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceOptionResponse(BaseModel):
|
||||||
|
value: str
|
||||||
|
label: str
|
||||||
|
description: str | None = None
|
||||||
|
kind: str | None = None
|
||||||
|
availability: Literal["available", "inactive", "unavailable"] = "available"
|
||||||
|
disabled: bool = False
|
||||||
|
source_module: str | None = None
|
||||||
|
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceOptionListResponse(BaseModel):
|
||||||
|
options: list[ReferenceOptionResponse] = Field(default_factory=list)
|
||||||
|
provider_available: bool = True
|
||||||
|
next_cursor: str | None = None
|
||||||
|
has_more: bool = False
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -55,12 +75,18 @@ class SwitchTenantRequest(BaseModel):
|
|||||||
tenant_id: str
|
tenant_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class SwitchActingContextRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
assignment_id: str | None = Field(default=None, max_length=36)
|
||||||
|
|
||||||
|
|
||||||
class TenantInfo(BaseModel):
|
class TenantInfo(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
slug: str
|
slug: str
|
||||||
name: str
|
name: str
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
default_locale: str = "en"
|
default_locale: str = "de"
|
||||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@@ -69,6 +95,45 @@ class TenantMembershipInfo(TenantInfo):
|
|||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class NavigationPreferencesPayload(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
contract_version: Literal["1"] = "1"
|
||||||
|
order: list[str] = Field(default_factory=list, max_length=256)
|
||||||
|
hidden: list[str] = Field(default_factory=list, max_length=256)
|
||||||
|
locked: list[str] = Field(default_factory=list, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
class AppearanceModeOverrides(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
accent: str
|
||||||
|
accent_foreground: str
|
||||||
|
surface: str
|
||||||
|
surface_foreground: str
|
||||||
|
success: str
|
||||||
|
success_foreground: str
|
||||||
|
info: str
|
||||||
|
info_foreground: str
|
||||||
|
warning: str
|
||||||
|
warning_foreground: str
|
||||||
|
danger: str
|
||||||
|
danger_foreground: str
|
||||||
|
|
||||||
|
|
||||||
|
class AppearanceOverridesDocument(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
schema_version: Literal["1"] = "1"
|
||||||
|
light: AppearanceModeOverrides
|
||||||
|
dark: AppearanceModeOverrides
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_accessibility(self) -> "AppearanceOverridesDocument":
|
||||||
|
normalize_appearance_overrides(self.model_dump(mode="json"))
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class UserUiPreferences(BaseModel):
|
class UserUiPreferences(BaseModel):
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
@@ -77,6 +142,20 @@ class UserUiPreferences(BaseModel):
|
|||||||
reduce_motion: bool = False
|
reduce_motion: bool = False
|
||||||
sticky_section_sidebars: bool = True
|
sticky_section_sidebars: bool = True
|
||||||
theme: Literal["system", "light", "dark"] = "system"
|
theme: Literal["system", "light", "dark"] = "system"
|
||||||
|
palette: Literal["default", "civic_blue", "forest", "plum"] | None = None
|
||||||
|
appearance_overrides: AppearanceOverridesDocument | None = None
|
||||||
|
navigation: NavigationPreferencesPayload | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EffectiveAppearanceInfo(BaseModel):
|
||||||
|
palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
|
||||||
|
source: Literal["user", "tenant", "system", "tenant_lock", "system_lock"] = "system"
|
||||||
|
locked: bool = False
|
||||||
|
system_default_palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
|
||||||
|
tenant_default_palette: Literal["default", "civic_blue", "forest", "plum"] | None = None
|
||||||
|
inherited_palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
|
||||||
|
custom_overrides: AppearanceOverridesDocument | None = None
|
||||||
|
custom_overrides_allowed: bool = False
|
||||||
|
|
||||||
|
|
||||||
class UserInfo(BaseModel):
|
class UserInfo(BaseModel):
|
||||||
@@ -92,6 +171,7 @@ class UserInfo(BaseModel):
|
|||||||
preferred_language: str | None = None
|
preferred_language: str | None = None
|
||||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||||
ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences)
|
ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences)
|
||||||
|
appearance: EffectiveAppearanceInfo = Field(default_factory=EffectiveAppearanceInfo)
|
||||||
|
|
||||||
|
|
||||||
class AuthSessionUserInfo(BaseModel):
|
class AuthSessionUserInfo(BaseModel):
|
||||||
@@ -160,6 +240,7 @@ class PrincipalContextInfo(BaseModel):
|
|||||||
api_key_id: str | None = None
|
api_key_id: str | None = None
|
||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
service_account_id: str | None = None
|
service_account_id: str | None = None
|
||||||
|
acting_assignment_id: str | None = None
|
||||||
acting_for_account_id: str | None = None
|
acting_for_account_id: str | None = None
|
||||||
email: str | None = None
|
email: str | None = None
|
||||||
display_name: str | None = None
|
display_name: str | None = None
|
||||||
@@ -185,7 +266,7 @@ class AuthProfileResponse(BaseModel):
|
|||||||
active_tenant: TenantInfo
|
active_tenant: TenantInfo
|
||||||
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
||||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||||
default_language: str = "en"
|
default_language: str = "de"
|
||||||
profile_loaded: bool = True
|
profile_loaded: bool = True
|
||||||
|
|
||||||
|
|
||||||
@@ -214,7 +295,7 @@ class LoginResponse(BaseModel):
|
|||||||
principal: PrincipalContextInfo | None = None
|
principal: PrincipalContextInfo | None = None
|
||||||
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
||||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||||
default_language: str = "en"
|
default_language: str = "de"
|
||||||
profile_loaded: bool = True
|
profile_loaded: bool = True
|
||||||
roles_loaded: bool = True
|
roles_loaded: bool = True
|
||||||
groups_loaded: bool = True
|
groups_loaded: bool = True
|
||||||
@@ -232,7 +313,7 @@ class MeResponse(BaseModel):
|
|||||||
principal: PrincipalContextInfo | None = None
|
principal: PrincipalContextInfo | None = None
|
||||||
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
||||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||||
default_language: str = "en"
|
default_language: str = "de"
|
||||||
profile_loaded: bool = True
|
profile_loaded: bool = True
|
||||||
roles_loaded: bool = True
|
roles_loaded: bool = True
|
||||||
groups_loaded: bool = True
|
groups_loaded: bool = True
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ from govoplan_core.core.events import (
|
|||||||
PlatformEvent,
|
PlatformEvent,
|
||||||
current_event_trace,
|
current_event_trace,
|
||||||
normalize_trace_id,
|
normalize_trace_id,
|
||||||
publish_platform_event,
|
emit_platform_event,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.institutional import GovernedContextEnvelope
|
||||||
from govoplan_core.core.runtime import get_registry
|
from govoplan_core.core.runtime import get_registry
|
||||||
from govoplan_core.privacy.retention import sanitize_audit_details_for_policy
|
from govoplan_core.privacy.retention import sanitize_audit_details_for_policy
|
||||||
from govoplan_core.security.redaction import redact_secret_values
|
from govoplan_core.security.redaction import redact_secret_values
|
||||||
@@ -171,9 +172,23 @@ class _NullAuditRecorder:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _publish_audit_platform_event(item: AuditRecordRef) -> None:
|
def _publish_audit_platform_event(
|
||||||
|
session: Session,
|
||||||
|
item: AuditRecordRef,
|
||||||
|
) -> None:
|
||||||
trace = _compact_trace(item.details.get("_trace") if isinstance(item.details, Mapping) else None)
|
trace = _compact_trace(item.details.get("_trace") if isinstance(item.details, Mapping) else None)
|
||||||
publish_platform_event(
|
raw_context = (
|
||||||
|
item.details.get("_institutional_context")
|
||||||
|
if isinstance(item.details, Mapping)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
institutional_context = (
|
||||||
|
GovernedContextEnvelope.from_mapping(raw_context)
|
||||||
|
if isinstance(raw_context, Mapping)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
emit_platform_event(
|
||||||
|
session,
|
||||||
PlatformEvent(
|
PlatformEvent(
|
||||||
type=item.action,
|
type=item.action,
|
||||||
module_id=_module_id_for_audit_action(item.action),
|
module_id=_module_id_for_audit_action(item.action),
|
||||||
@@ -190,6 +205,7 @@ def _publish_audit_platform_event(item: AuditRecordRef) -> None:
|
|||||||
tenant=EventTenantRef(id=item.tenant_id) if item.tenant_id else None,
|
tenant=EventTenantRef(id=item.tenant_id) if item.tenant_id else None,
|
||||||
resource=EventObjectRef(type=item.object_type, id=item.object_id) if item.object_type else None,
|
resource=EventObjectRef(type=item.object_type, id=item.object_id) if item.object_type else None,
|
||||||
classification="internal",
|
classification="internal",
|
||||||
|
institutional_context=institutional_context,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -207,6 +223,7 @@ def audit_event(
|
|||||||
details: dict[str, Any] | None = None,
|
details: dict[str, Any] | None = None,
|
||||||
correlation_id: str | None = None,
|
correlation_id: str | None = None,
|
||||||
causation_id: str | None = None,
|
causation_id: str | None = None,
|
||||||
|
institutional_context: GovernedContextEnvelope | None = None,
|
||||||
commit: bool = False,
|
commit: bool = False,
|
||||||
) -> AuditRecordRef:
|
) -> AuditRecordRef:
|
||||||
"""Persist one audit event.
|
"""Persist one audit event.
|
||||||
@@ -219,8 +236,17 @@ def audit_event(
|
|||||||
if scope not in {"tenant", "system"}:
|
if scope not in {"tenant", "system"}:
|
||||||
raise ValueError(f"Unsupported audit scope: {scope}")
|
raise ValueError(f"Unsupported audit scope: {scope}")
|
||||||
|
|
||||||
|
if (
|
||||||
|
institutional_context is not None
|
||||||
|
and tenant_id is not None
|
||||||
|
and institutional_context.tenant_id != tenant_id
|
||||||
|
):
|
||||||
|
raise ValueError("Audit institutional context belongs to another tenant")
|
||||||
|
raw_details = dict(details or {})
|
||||||
|
if institutional_context is not None:
|
||||||
|
raw_details["_institutional_context"] = institutional_context.to_dict()
|
||||||
traced_details, trace = _trace_details(
|
traced_details, trace = _trace_details(
|
||||||
_sanitize_details(details or {}),
|
_sanitize_details(raw_details),
|
||||||
correlation_id=correlation_id,
|
correlation_id=correlation_id,
|
||||||
causation_id=causation_id,
|
causation_id=causation_id,
|
||||||
)
|
)
|
||||||
@@ -238,6 +264,7 @@ def audit_event(
|
|||||||
api_key_id=api_key_id,
|
api_key_id=api_key_id,
|
||||||
resource_type=object_type,
|
resource_type=object_type,
|
||||||
resource_id=object_id,
|
resource_id=object_id,
|
||||||
|
institutional_context=institutional_context,
|
||||||
details=stored_details,
|
details=stored_details,
|
||||||
))
|
))
|
||||||
record_change(
|
record_change(
|
||||||
@@ -252,7 +279,7 @@ def audit_event(
|
|||||||
actor_id=user_id or api_key_id,
|
actor_id=user_id or api_key_id,
|
||||||
payload={"scope": scope, "action": action, "object_type": object_type, "object_id": object_id},
|
payload={"scope": scope, "action": action, "object_type": object_type, "object_id": object_id},
|
||||||
)
|
)
|
||||||
_publish_audit_platform_event(item)
|
_publish_audit_platform_event(session, item)
|
||||||
if commit:
|
if commit:
|
||||||
session.commit()
|
session.commit()
|
||||||
return item
|
return item
|
||||||
@@ -269,6 +296,7 @@ def audit_from_principal(
|
|||||||
details: dict[str, Any] | None = None,
|
details: dict[str, Any] | None = None,
|
||||||
correlation_id: str | None = None,
|
correlation_id: str | None = None,
|
||||||
causation_id: str | None = None,
|
causation_id: str | None = None,
|
||||||
|
institutional_context: GovernedContextEnvelope | None = None,
|
||||||
commit: bool = False,
|
commit: bool = False,
|
||||||
) -> AuditRecordRef:
|
) -> AuditRecordRef:
|
||||||
return audit_event(
|
return audit_event(
|
||||||
@@ -283,5 +311,6 @@ def audit_from_principal(
|
|||||||
details=details,
|
details=details,
|
||||||
correlation_id=correlation_id,
|
correlation_id=correlation_id,
|
||||||
causation_id=causation_id,
|
causation_id=causation_id,
|
||||||
|
institutional_context=institutional_context,
|
||||||
commit=commit,
|
commit=commit,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ class ApiPrincipal:
|
|||||||
def acting_for_account_id(self) -> str | None:
|
def acting_for_account_id(self) -> str | None:
|
||||||
return self.principal.acting_for_account_id
|
return self.principal.acting_for_account_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def acting_assignment_id(self) -> str | None:
|
||||||
|
return self.principal.acting_assignment_id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def auth_method(self) -> str:
|
def auth_method(self) -> str:
|
||||||
return self.principal.auth_method
|
return self.principal.auth_method
|
||||||
@@ -118,7 +122,7 @@ def _registry_from_request(request: Request) -> PlatformRegistry | None:
|
|||||||
def _api_principal_provider_from_request(request: Request) -> ApiPrincipalProvider:
|
def _api_principal_provider_from_request(request: Request) -> ApiPrincipalProvider:
|
||||||
registry = _registry_from_request(request)
|
registry = _registry_from_request(request)
|
||||||
if registry is None or not registry.has_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER):
|
if registry is None or not registry.has_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER):
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Auth provider is not available")
|
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Auth provider is not available")
|
||||||
capability = registry.require_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER)
|
capability = registry.require_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER)
|
||||||
if not isinstance(capability, ApiPrincipalProvider):
|
if not isinstance(capability, ApiPrincipalProvider):
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid auth provider capability")
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid auth provider capability")
|
||||||
@@ -131,6 +135,9 @@ def get_api_principal(
|
|||||||
authorization: str | None = Header(default=None),
|
authorization: str | None = Header(default=None),
|
||||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||||
) -> ApiPrincipal:
|
) -> ApiPrincipal:
|
||||||
|
cached = getattr(request.state, "govoplan_api_principal", None)
|
||||||
|
if isinstance(cached, ApiPrincipal):
|
||||||
|
return cached
|
||||||
principal = _api_principal_provider_from_request(request).resolve_api_principal(
|
principal = _api_principal_provider_from_request(request).resolve_api_principal(
|
||||||
request,
|
request,
|
||||||
session,
|
session,
|
||||||
@@ -139,6 +146,7 @@ def get_api_principal(
|
|||||||
)
|
)
|
||||||
if not isinstance(principal, ApiPrincipal):
|
if not isinstance(principal, ApiPrincipal):
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid API principal")
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid API principal")
|
||||||
|
request.state.govoplan_api_principal = principal
|
||||||
return principal
|
return principal
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1407
-32
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,199 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from govoplan_core.core.runtime_coordination import (
|
||||||
|
LeaseClaim,
|
||||||
|
acquire_lease,
|
||||||
|
release_lease,
|
||||||
|
renew_lease,
|
||||||
|
runtime_identity,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.session import configure_database, get_database
|
||||||
|
from govoplan_core.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Run one process while holding a database-fenced deployment lease."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--resource", required=True, help="Stable cluster-wide lease key."
|
||||||
|
)
|
||||||
|
parser.add_argument("--ttl-seconds", type=int, default=60)
|
||||||
|
parser.add_argument("--renew-seconds", type=int, default=15)
|
||||||
|
parser.add_argument("--wait-seconds", type=int, default=0)
|
||||||
|
parser.add_argument("command", nargs=argparse.REMAINDER)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
command = list(args.command)
|
||||||
|
if command and command[0] == "--":
|
||||||
|
command.pop(0)
|
||||||
|
if not command:
|
||||||
|
raise SystemExit("fenced-run requires a command after --")
|
||||||
|
if args.ttl_seconds < 10:
|
||||||
|
raise SystemExit("--ttl-seconds must be at least 10")
|
||||||
|
if args.renew_seconds < 2 or args.renew_seconds * 2 >= args.ttl_seconds:
|
||||||
|
raise SystemExit("--renew-seconds must be less than half the lease TTL")
|
||||||
|
configure_database(settings.database_url)
|
||||||
|
identity = runtime_identity(
|
||||||
|
settings,
|
||||||
|
software_version=_core_version(),
|
||||||
|
role=str(settings.runtime_role or "deployment"),
|
||||||
|
)
|
||||||
|
claim = _wait_for_lease(
|
||||||
|
resource=args.resource,
|
||||||
|
identity=identity,
|
||||||
|
ttl_seconds=args.ttl_seconds,
|
||||||
|
wait_seconds=max(0, args.wait_seconds),
|
||||||
|
)
|
||||||
|
if claim is None:
|
||||||
|
print(
|
||||||
|
f"lease unavailable: {args.resource}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 75
|
||||||
|
|
||||||
|
process = subprocess.Popen(command) # noqa: S603 - argv is an operator-owned container command
|
||||||
|
stop = threading.Event()
|
||||||
|
fence_lost = threading.Event()
|
||||||
|
renewer = threading.Thread(
|
||||||
|
target=_renew_loop,
|
||||||
|
kwargs={
|
||||||
|
"claim": claim,
|
||||||
|
"ttl_seconds": args.ttl_seconds,
|
||||||
|
"renew_seconds": args.renew_seconds,
|
||||||
|
"stop": stop,
|
||||||
|
"fence_lost": fence_lost,
|
||||||
|
"process": process,
|
||||||
|
},
|
||||||
|
daemon=True,
|
||||||
|
name=f"govoplan-fence:{args.resource}",
|
||||||
|
)
|
||||||
|
renewer.start()
|
||||||
|
previous_handlers = _forward_signals(process)
|
||||||
|
try:
|
||||||
|
return_code = process.wait()
|
||||||
|
finally:
|
||||||
|
stop.set()
|
||||||
|
renewer.join(timeout=args.renew_seconds + 2)
|
||||||
|
_restore_signals(previous_handlers)
|
||||||
|
_release(claim)
|
||||||
|
if fence_lost.is_set():
|
||||||
|
return 74
|
||||||
|
return int(return_code)
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_lease(
|
||||||
|
*,
|
||||||
|
resource: str,
|
||||||
|
identity,
|
||||||
|
ttl_seconds: int,
|
||||||
|
wait_seconds: int,
|
||||||
|
) -> LeaseClaim | None:
|
||||||
|
deadline = time.monotonic() + wait_seconds
|
||||||
|
while True:
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
claim = acquire_lease(
|
||||||
|
session,
|
||||||
|
installation_id=identity.installation_id,
|
||||||
|
resource_key=resource,
|
||||||
|
holder_node_id=identity.node_id,
|
||||||
|
holder_incarnation=identity.incarnation,
|
||||||
|
ttl_seconds=ttl_seconds,
|
||||||
|
metadata={"role": identity.role},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
if claim is not None or time.monotonic() >= deadline:
|
||||||
|
return claim
|
||||||
|
time.sleep(min(2, max(0.1, deadline - time.monotonic())))
|
||||||
|
|
||||||
|
|
||||||
|
def _renew_loop(
|
||||||
|
*,
|
||||||
|
claim: LeaseClaim,
|
||||||
|
ttl_seconds: int,
|
||||||
|
renew_seconds: int,
|
||||||
|
stop: threading.Event,
|
||||||
|
fence_lost: threading.Event,
|
||||||
|
process: subprocess.Popen[bytes],
|
||||||
|
) -> None:
|
||||||
|
active_claim = claim
|
||||||
|
while not stop.wait(renew_seconds):
|
||||||
|
try:
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
active_claim = renew_lease(
|
||||||
|
session,
|
||||||
|
active_claim,
|
||||||
|
ttl_seconds=ttl_seconds,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except Exception: # noqa: BLE001 - any renewal failure loses authority
|
||||||
|
fence_lost.set()
|
||||||
|
_terminate_process(process)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _terminate_process(
|
||||||
|
process: subprocess.Popen[bytes],
|
||||||
|
*,
|
||||||
|
timeout_seconds: float = 5.0,
|
||||||
|
) -> None:
|
||||||
|
if process.poll() is not None:
|
||||||
|
return
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
process.wait(timeout=timeout_seconds)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
process.wait(timeout=timeout_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def _release(claim: LeaseClaim) -> None:
|
||||||
|
try:
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
release_lease(session, claim)
|
||||||
|
session.commit()
|
||||||
|
except Exception: # noqa: BLE001 - authority is already lost; release is best effort
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _forward_signals(
|
||||||
|
process: subprocess.Popen[bytes],
|
||||||
|
) -> dict[int, signal.Handlers]:
|
||||||
|
previous: dict[int, signal.Handlers] = {}
|
||||||
|
|
||||||
|
def forward(signum, _frame) -> None:
|
||||||
|
if process.poll() is None:
|
||||||
|
process.send_signal(signum)
|
||||||
|
|
||||||
|
for signum in (signal.SIGTERM, signal.SIGINT):
|
||||||
|
previous[signum] = signal.getsignal(signum)
|
||||||
|
signal.signal(signum, forward)
|
||||||
|
return previous
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_signals(previous: dict[int, signal.Handlers]) -> None:
|
||||||
|
for signum, handler in previous.items():
|
||||||
|
signal.signal(signum, handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _core_version() -> str:
|
||||||
|
try:
|
||||||
|
return version("govoplan-core")
|
||||||
|
except PackageNotFoundError:
|
||||||
|
return "development"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import stat
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER,
|
||||||
|
FirstAdminProvisioner,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.first_admin import (
|
||||||
|
FirstAdminEnrollmentError,
|
||||||
|
first_admin_enrollment_status,
|
||||||
|
issue_first_admin_credential,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.module_management import (
|
||||||
|
load_startup_enabled_modules,
|
||||||
|
startup_candidate_module_ids,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.modules import ModuleContext
|
||||||
|
from govoplan_core.core.runtime import configure_runtime
|
||||||
|
from govoplan_core.db.session import configure_database, get_database
|
||||||
|
from govoplan_core.server.registry import (
|
||||||
|
available_module_manifests,
|
||||||
|
build_platform_registry,
|
||||||
|
)
|
||||||
|
from govoplan_core.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Manage the single-use production first-administrator credential",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"command",
|
||||||
|
choices=("status", "issue", "recover"),
|
||||||
|
help="Inspect readiness, issue the initial credential, or rotate lost/expired material.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--database-url", default=settings.database_url)
|
||||||
|
parser.add_argument("--installation-id", default=settings.installation_id)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
type=Path,
|
||||||
|
default=Path(settings.first_admin_enrollment_file),
|
||||||
|
help="Root-readable/equivalent JSON credential artifact.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--ttl-seconds",
|
||||||
|
type=int,
|
||||||
|
default=settings.first_admin_enrollment_ttl_seconds,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--reason",
|
||||||
|
default=None,
|
||||||
|
help="Audited local-operator reason for issue or recovery.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
configure_database(args.database_url)
|
||||||
|
provisioner = _configure_first_admin_provisioner()
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
if args.command == "status":
|
||||||
|
enrollment = first_admin_enrollment_status(
|
||||||
|
session,
|
||||||
|
installation_id=args.installation_id,
|
||||||
|
provisioner=provisioner,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"installation_id": args.installation_id,
|
||||||
|
"enrollment_required": enrollment.enrollment_required,
|
||||||
|
"credential_active": enrollment.credential_active,
|
||||||
|
"state": enrollment.state,
|
||||||
|
"generation": enrollment.generation,
|
||||||
|
"expires_at": (
|
||||||
|
enrollment.expires_at.isoformat()
|
||||||
|
if enrollment.expires_at is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"readiness": enrollment.readiness,
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
reason = args.reason or (
|
||||||
|
"initial production administrator enrollment"
|
||||||
|
if args.command == "issue"
|
||||||
|
else "local operator recovery of first-administrator enrollment"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
credential = issue_first_admin_credential(
|
||||||
|
session,
|
||||||
|
installation_id=args.installation_id,
|
||||||
|
provisioner=provisioner,
|
||||||
|
ttl_seconds=args.ttl_seconds,
|
||||||
|
reason=reason,
|
||||||
|
replace_active=args.command == "recover",
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"installation_id": args.installation_id,
|
||||||
|
"endpoint": "/api/v1/bootstrap/first-admin",
|
||||||
|
"header": "X-GovOPlaN-Enrollment-Token",
|
||||||
|
"enrollment_token": credential.secret,
|
||||||
|
"fingerprint": credential.fingerprint,
|
||||||
|
"generation": credential.generation,
|
||||||
|
"expires_at": credential.expires_at.isoformat(),
|
||||||
|
}
|
||||||
|
previous = _secure_file_snapshot(args.output)
|
||||||
|
_write_private_json(args.output, payload)
|
||||||
|
try:
|
||||||
|
session.commit()
|
||||||
|
except Exception:
|
||||||
|
session.rollback()
|
||||||
|
_restore_secure_file(args.output, previous)
|
||||||
|
raise
|
||||||
|
except FirstAdminEnrollmentError as exc:
|
||||||
|
session.rollback()
|
||||||
|
parser.error(str(exc))
|
||||||
|
|
||||||
|
print(f"First-administrator credential written to {args.output}")
|
||||||
|
print(f"Fingerprint: {credential.fingerprint}")
|
||||||
|
print(f"Expires: {credential.expires_at.isoformat()}")
|
||||||
|
print("The secret was not printed. Read it from the restricted artifact on the host.")
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_first_admin_provisioner() -> FirstAdminProvisioner:
|
||||||
|
raw_enabled = load_startup_enabled_modules(settings.enabled_modules)
|
||||||
|
candidates = startup_candidate_module_ids(settings.enabled_modules, raw_enabled)
|
||||||
|
available = available_module_manifests(
|
||||||
|
enabled_modules=candidates,
|
||||||
|
ignore_load_errors=True,
|
||||||
|
)
|
||||||
|
enabled = load_startup_enabled_modules(
|
||||||
|
settings.enabled_modules,
|
||||||
|
available=available,
|
||||||
|
)
|
||||||
|
registry = build_platform_registry(enabled)
|
||||||
|
context = ModuleContext(registry=registry, settings=settings)
|
||||||
|
registry.configure_capability_context(context)
|
||||||
|
configure_runtime(context)
|
||||||
|
if not registry.has_capability(CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER):
|
||||||
|
raise RuntimeError(
|
||||||
|
"Install and enable the Access module before issuing a first-administrator credential."
|
||||||
|
)
|
||||||
|
capability = registry.require_capability(CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER)
|
||||||
|
if not isinstance(capability, FirstAdminProvisioner):
|
||||||
|
raise RuntimeError("The Access first-administrator capability is invalid.")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _secure_file_snapshot(path: Path) -> tuple[bytes, int] | None:
|
||||||
|
try:
|
||||||
|
metadata = path.lstat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
if not stat.S_ISREG(metadata.st_mode):
|
||||||
|
raise RuntimeError(f"Refusing to replace non-regular credential artifact: {path}")
|
||||||
|
if metadata.st_uid != os.geteuid():
|
||||||
|
raise RuntimeError(f"Credential artifact is not owned by the current operator: {path}")
|
||||||
|
if stat.S_IMODE(metadata.st_mode) & 0o077:
|
||||||
|
raise RuntimeError(f"Credential artifact permissions are too broad: {path}")
|
||||||
|
return path.read_bytes(), stat.S_IMODE(metadata.st_mode)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_private_json(path: Path, payload: dict[str, Any]) -> None:
|
||||||
|
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||||
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||||
|
if hasattr(os, "O_NOFOLLOW"):
|
||||||
|
flags |= os.O_NOFOLLOW
|
||||||
|
descriptor = os.open(temporary, flags, 0o600)
|
||||||
|
try:
|
||||||
|
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
||||||
|
json.dump(payload, stream, indent=2, sort_keys=True)
|
||||||
|
stream.write("\n")
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(temporary, path)
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
_fsync_directory(path.parent)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
temporary.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_secure_file(path: Path, snapshot: tuple[bytes, int] | None) -> None:
|
||||||
|
if snapshot is None:
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
return
|
||||||
|
content, mode = snapshot
|
||||||
|
temporary = path.with_name(f".{path.name}.{os.getpid()}.restore")
|
||||||
|
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||||
|
try:
|
||||||
|
with os.fdopen(descriptor, "wb") as stream:
|
||||||
|
stream.write(content)
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(temporary, path)
|
||||||
|
os.chmod(path, mode)
|
||||||
|
_fsync_directory(path.parent)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
temporary.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _fsync_directory(path: Path) -> None:
|
||||||
|
if not hasattr(os, "O_DIRECTORY"):
|
||||||
|
return
|
||||||
|
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
|
||||||
|
try:
|
||||||
|
os.fsync(descriptor)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -12,6 +12,7 @@ from govoplan_core.db.migrations import (
|
|||||||
migrate_database,
|
migrate_database,
|
||||||
run_registered_module_migration_tasks,
|
run_registered_module_migration_tasks,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.db.migration_lock import deployment_migration_lock
|
||||||
from govoplan_core.db.session import configure_database, get_database
|
from govoplan_core.db.session import configure_database, get_database
|
||||||
from govoplan_core.settings import settings
|
from govoplan_core.settings import settings
|
||||||
|
|
||||||
@@ -28,6 +29,12 @@ def main() -> None:
|
|||||||
parser.add_argument("--enabled-module", action="append", default=[], help="Target enabled module id used to discover module migrations; may be repeated.")
|
parser.add_argument("--enabled-module", action="append", default=[], help="Target enabled module id used to discover module migrations; may be repeated.")
|
||||||
parser.add_argument("--migration-module", action="append", default=[], help="Module id whose migration heads should be upgraded in this order before final heads.")
|
parser.add_argument("--migration-module", action="append", default=[], help="Module id whose migration heads should be upgraded in this order before final heads.")
|
||||||
parser.add_argument("--migration-task-record-output", type=Path, help="Write executed module migration task records to this JSON file.")
|
parser.add_argument("--migration-task-record-output", type=Path, help="Write executed module migration task records to this JSON file.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--migration-lock-timeout-seconds",
|
||||||
|
type=float,
|
||||||
|
default=900.0,
|
||||||
|
help="Maximum wait for the deployment-wide PostgreSQL advisory lock.",
|
||||||
|
)
|
||||||
parser.add_argument("--with-dev-data", action="store_true", help="Create default tenant/user/roles and a development API key")
|
parser.add_argument("--with-dev-data", action="store_true", help="Create default tenant/user/roles and a development API key")
|
||||||
parser.add_argument("--dev-api-key", default=settings.dev_bootstrap_api_key, help="Development API key secret to create")
|
parser.add_argument("--dev-api-key", default=settings.dev_bootstrap_api_key, help="Development API key secret to create")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
@@ -37,26 +44,32 @@ def main() -> None:
|
|||||||
migration_order = tuple(args.migration_module) if args.migration_module else None
|
migration_order = tuple(args.migration_module) if args.migration_module else None
|
||||||
task_records: list[dict[str, object]] = []
|
task_records: list[dict[str, object]] = []
|
||||||
try:
|
try:
|
||||||
_run_migration_tasks(
|
with deployment_migration_lock(
|
||||||
task_records,
|
args.database_url,
|
||||||
database_url=args.database_url,
|
installation_id=settings.installation_id,
|
||||||
enabled_modules=enabled_modules,
|
|
||||||
migration_order=migration_order,
|
|
||||||
phases=PRE_MIGRATION_TASK_PHASES,
|
|
||||||
)
|
|
||||||
migration = migrate_database(
|
|
||||||
database_url=args.database_url,
|
|
||||||
enabled_modules=enabled_modules,
|
|
||||||
migration_module_order=migration_order,
|
|
||||||
migration_track=args.migration_track,
|
migration_track=args.migration_track,
|
||||||
)
|
timeout_seconds=args.migration_lock_timeout_seconds,
|
||||||
_run_migration_tasks(
|
):
|
||||||
task_records,
|
_run_migration_tasks(
|
||||||
database_url=args.database_url,
|
task_records,
|
||||||
enabled_modules=enabled_modules,
|
database_url=args.database_url,
|
||||||
migration_order=migration_order,
|
enabled_modules=enabled_modules,
|
||||||
phases=POST_MIGRATION_TASK_PHASES,
|
migration_order=migration_order,
|
||||||
)
|
phases=PRE_MIGRATION_TASK_PHASES,
|
||||||
|
)
|
||||||
|
migration = migrate_database(
|
||||||
|
database_url=args.database_url,
|
||||||
|
enabled_modules=enabled_modules,
|
||||||
|
migration_module_order=migration_order,
|
||||||
|
migration_track=args.migration_track,
|
||||||
|
)
|
||||||
|
_run_migration_tasks(
|
||||||
|
task_records,
|
||||||
|
database_url=args.database_url,
|
||||||
|
enabled_modules=enabled_modules,
|
||||||
|
migration_order=migration_order,
|
||||||
|
phases=POST_MIGRATION_TASK_PHASES,
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
if args.migration_task_record_output:
|
if args.migration_task_record_output:
|
||||||
args.migration_task_record_output.parent.mkdir(parents=True, exist_ok=True)
|
args.migration_task_record_output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
@@ -33,6 +34,10 @@ from govoplan_core.core.module_installer_notifications import (
|
|||||||
installer_notification_priority,
|
installer_notification_priority,
|
||||||
installer_notification_subject,
|
installer_notification_subject,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.runtime_coordination import (
|
||||||
|
bind_process_runtime_identity,
|
||||||
|
runtime_identity,
|
||||||
|
)
|
||||||
from govoplan_core.core.module_license import issue_module_license, module_license_diagnostics
|
from govoplan_core.core.module_license import issue_module_license, module_license_diagnostics
|
||||||
from govoplan_core.core.module_package_catalog import sign_module_package_catalog, validate_module_package_catalog
|
from govoplan_core.core.module_package_catalog import sign_module_package_catalog, validate_module_package_catalog
|
||||||
from govoplan_core.core.module_management import (
|
from govoplan_core.core.module_management import (
|
||||||
@@ -107,11 +112,27 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||||||
def main() -> int:
|
def main() -> int:
|
||||||
args = _build_parser().parse_args()
|
args = _build_parser().parse_args()
|
||||||
runtime_dir = args.runtime_dir or default_installer_runtime_dir(args.database_url)
|
runtime_dir = args.runtime_dir or default_installer_runtime_dir(args.database_url)
|
||||||
|
bind_process_runtime_identity(
|
||||||
|
runtime_identity(
|
||||||
|
settings,
|
||||||
|
software_version=_core_version(),
|
||||||
|
role="installer",
|
||||||
|
)
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
return _dispatch_command(args=args, runtime_dir=runtime_dir)
|
return _dispatch_command(args=args, runtime_dir=runtime_dir)
|
||||||
except ModuleInstallerError as exc:
|
except ModuleInstallerError as exc:
|
||||||
print(f"error: {exc}", file=sys.stderr)
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
finally:
|
||||||
|
bind_process_runtime_identity(None)
|
||||||
|
|
||||||
|
|
||||||
|
def _core_version() -> str:
|
||||||
|
try:
|
||||||
|
return version("govoplan-core")
|
||||||
|
except PackageNotFoundError:
|
||||||
|
return "development"
|
||||||
|
|
||||||
|
|
||||||
def _dispatch_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
|
def _dispatch_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from govoplan_core.db.migrations import (
|
||||||
|
configured_migration_heads,
|
||||||
|
database_migration_heads,
|
||||||
|
)
|
||||||
|
from govoplan_core.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Wait until the database is at this release's migration heads."
|
||||||
|
)
|
||||||
|
parser.add_argument("--database-url", default=settings.database_url)
|
||||||
|
parser.add_argument(
|
||||||
|
"--migration-track",
|
||||||
|
default=settings.migration_track,
|
||||||
|
choices=("release", "dev"),
|
||||||
|
)
|
||||||
|
parser.add_argument("--timeout-seconds", type=float, default=900.0)
|
||||||
|
parser.add_argument("--poll-seconds", type=float, default=2.0)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
if args.timeout_seconds < 0 or args.poll_seconds <= 0:
|
||||||
|
raise SystemExit("timeouts must be non-negative and polling must be positive")
|
||||||
|
expected = configured_migration_heads(
|
||||||
|
args.database_url,
|
||||||
|
migration_track=args.migration_track,
|
||||||
|
)
|
||||||
|
deadline = time.monotonic() + args.timeout_seconds
|
||||||
|
last_error = ""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
actual = database_migration_heads(args.database_url)
|
||||||
|
if actual == expected:
|
||||||
|
print("Database migration heads are ready: " + ",".join(actual))
|
||||||
|
return 0
|
||||||
|
last_error = (
|
||||||
|
f"database heads={','.join(actual) or '<none>'}; "
|
||||||
|
f"expected={','.join(expected) or '<none>'}"
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - connection may become ready later
|
||||||
|
last_error = f"{type(exc).__name__}: {exc}"
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
print(
|
||||||
|
"Database did not reach configured migration heads: " + last_error,
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 75
|
||||||
|
time.sleep(min(args.poll_seconds, max(0.05, deadline - time.monotonic())))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -6,6 +6,7 @@ from datetime import datetime
|
|||||||
from typing import Literal, Protocol, cast, runtime_checkable
|
from typing import Literal, Protocol, cast, runtime_checkable
|
||||||
|
|
||||||
from govoplan_core.core.modules import AccessDecision
|
from govoplan_core.core.modules import AccessDecision
|
||||||
|
from govoplan_core.core.institutional import GovernedContextEnvelope
|
||||||
|
|
||||||
|
|
||||||
ACCESS_MODULE_ID = "access"
|
ACCESS_MODULE_ID = "access"
|
||||||
@@ -20,14 +21,22 @@ CAPABILITY_ACCESS_RESOURCE_ACCESS = "access.resourceAccess"
|
|||||||
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY = "access.semanticDirectory"
|
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY = "access.semanticDirectory"
|
||||||
CAPABILITY_ACCESS_EXPLANATION = "access.explanation"
|
CAPABILITY_ACCESS_EXPLANATION = "access.explanation"
|
||||||
CAPABILITY_ACCESS_TENANT_PROVISIONER = "access.tenantProvisioner"
|
CAPABILITY_ACCESS_TENANT_PROVISIONER = "access.tenantProvisioner"
|
||||||
|
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER = "access.firstAdminProvisioner"
|
||||||
CAPABILITY_ACCESS_ADMINISTRATION = "access.administration"
|
CAPABILITY_ACCESS_ADMINISTRATION = "access.administration"
|
||||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER = "access.governanceMaterializer"
|
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER = "access.governanceMaterializer"
|
||||||
|
CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1 = "access.governanceProjection.v1"
|
||||||
|
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS = (
|
||||||
|
"policy.access_explanation_subjects"
|
||||||
|
)
|
||||||
CAPABILITY_TENANCY_TENANT_RESOLVER = "tenancy.tenantResolver"
|
CAPABILITY_TENANCY_TENANT_RESOLVER = "tenancy.tenantResolver"
|
||||||
CAPABILITY_AUDIT_SINK = "audit.sink"
|
CAPABILITY_AUDIT_SINK = "audit.sink"
|
||||||
CAPABILITY_AUDIT_RECORDER = "audit.recorder"
|
CAPABILITY_AUDIT_RECORDER = "audit.recorder"
|
||||||
CAPABILITY_AUDIT_RETENTION = "audit.retention"
|
CAPABILITY_AUDIT_RETENTION = "audit.retention"
|
||||||
|
|
||||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER = "auth.apiPrincipalProvider"
|
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER = "auth.apiPrincipalProvider"
|
||||||
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER = (
|
||||||
|
"auth.automationPrincipalProvider"
|
||||||
|
)
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER = "auth.principalResolver"
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER = "auth.principalResolver"
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR = "auth.permissionEvaluator"
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR = "auth.permissionEvaluator"
|
||||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER = "auth.tenantContextSwitcher"
|
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER = "auth.tenantContextSwitcher"
|
||||||
@@ -41,13 +50,16 @@ ACCESS_CAPABILITY_NAMES = frozenset(
|
|||||||
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
|
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
|
||||||
CAPABILITY_ACCESS_EXPLANATION,
|
CAPABILITY_ACCESS_EXPLANATION,
|
||||||
CAPABILITY_ACCESS_TENANT_PROVISIONER,
|
CAPABILITY_ACCESS_TENANT_PROVISIONER,
|
||||||
|
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER,
|
||||||
CAPABILITY_ACCESS_ADMINISTRATION,
|
CAPABILITY_ACCESS_ADMINISTRATION,
|
||||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
|
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
|
||||||
|
CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1,
|
||||||
CAPABILITY_TENANCY_TENANT_RESOLVER,
|
CAPABILITY_TENANCY_TENANT_RESOLVER,
|
||||||
CAPABILITY_AUDIT_SINK,
|
CAPABILITY_AUDIT_SINK,
|
||||||
CAPABILITY_AUDIT_RECORDER,
|
CAPABILITY_AUDIT_RECORDER,
|
||||||
CAPABILITY_AUDIT_RETENTION,
|
CAPABILITY_AUDIT_RETENTION,
|
||||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
||||||
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
||||||
@@ -57,12 +69,19 @@ ACCESS_CAPABILITY_NAMES = frozenset(
|
|||||||
AUTH_CAPABILITY_NAMES = frozenset(
|
AUTH_CAPABILITY_NAMES = frozenset(
|
||||||
{
|
{
|
||||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
||||||
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
DEFAULT_CAPABILITY_PROVIDERS: Mapping[str, str] = {
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER: ACCESS_MODULE_ID,
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR: ACCESS_MODULE_ID,
|
||||||
|
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER: ACCESS_MODULE_ID,
|
||||||
|
}
|
||||||
|
|
||||||
AuthMethod = Literal["session", "api_key", "service_account"]
|
AuthMethod = Literal["session", "api_key", "service_account"]
|
||||||
AccessSubjectKind = Literal[
|
AccessSubjectKind = Literal[
|
||||||
"identity",
|
"identity",
|
||||||
@@ -114,6 +133,7 @@ class PrincipalRef:
|
|||||||
api_key_id: str | None = None
|
api_key_id: str | None = None
|
||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
service_account_id: str | None = None
|
service_account_id: str | None = None
|
||||||
|
acting_assignment_id: str | None = None
|
||||||
acting_for_account_id: str | None = None
|
acting_for_account_id: str | None = None
|
||||||
email: str | None = None
|
email: str | None = None
|
||||||
display_name: str | None = None
|
display_name: str | None = None
|
||||||
@@ -133,6 +153,7 @@ class PrincipalRef:
|
|||||||
"api_key_id": self.api_key_id,
|
"api_key_id": self.api_key_id,
|
||||||
"session_id": self.session_id,
|
"session_id": self.session_id,
|
||||||
"service_account_id": self.service_account_id,
|
"service_account_id": self.service_account_id,
|
||||||
|
"acting_assignment_id": self.acting_assignment_id,
|
||||||
"acting_for_account_id": self.acting_for_account_id,
|
"acting_for_account_id": self.acting_for_account_id,
|
||||||
"email": self.email,
|
"email": self.email,
|
||||||
"display_name": self.display_name,
|
"display_name": self.display_name,
|
||||||
@@ -154,12 +175,22 @@ class PrincipalRef:
|
|||||||
api_key_id=_optional_str(value.get("api_key_id")),
|
api_key_id=_optional_str(value.get("api_key_id")),
|
||||||
session_id=_optional_str(value.get("session_id")),
|
session_id=_optional_str(value.get("session_id")),
|
||||||
service_account_id=_optional_str(value.get("service_account_id")),
|
service_account_id=_optional_str(value.get("service_account_id")),
|
||||||
|
acting_assignment_id=_optional_str(value.get("acting_assignment_id")),
|
||||||
acting_for_account_id=_optional_str(value.get("acting_for_account_id")),
|
acting_for_account_id=_optional_str(value.get("acting_for_account_id")),
|
||||||
email=_optional_str(value.get("email")),
|
email=_optional_str(value.get("email")),
|
||||||
display_name=_optional_str(value.get("display_name")),
|
display_name=_optional_str(value.get("display_name")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AccessExplanationSubjectDecision:
|
||||||
|
allow_other_users: bool
|
||||||
|
reason: str
|
||||||
|
source: str
|
||||||
|
required_scope: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
def _optional_str(value: object | None) -> str | None:
|
def _optional_str(value: object | None) -> str | None:
|
||||||
return str(value) if value is not None else None
|
return str(value) if value is not None else None
|
||||||
|
|
||||||
@@ -327,6 +358,19 @@ class DevelopmentBootstrapRef:
|
|||||||
created_api_key: CreatedApiKeyRef | None = None
|
created_api_key: CreatedApiKeyRef | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FirstSystemAdministratorRef:
|
||||||
|
account_id: str
|
||||||
|
email: str
|
||||||
|
display_name: str | None = None
|
||||||
|
membership_id: str | None = None
|
||||||
|
tenant_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FirstAdminProvisioningError(RuntimeError):
|
||||||
|
"""Safe, user-facing rejection from the Access enrollment boundary."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class TenantContextSwitchRef:
|
class TenantContextSwitchRef:
|
||||||
account_id: str
|
account_id: str
|
||||||
@@ -348,6 +392,82 @@ class GovernanceTemplateMaterialization:
|
|||||||
required: bool = False
|
required: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
GovernanceProjectionOperation = Literal["upsert", "remove"]
|
||||||
|
GovernanceProjectionStatus = Literal[
|
||||||
|
"created",
|
||||||
|
"updated",
|
||||||
|
"unchanged",
|
||||||
|
"removed",
|
||||||
|
"absent",
|
||||||
|
"blocked",
|
||||||
|
"failed",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class GovernanceProjectionCommand:
|
||||||
|
"""Stable Access-owned input for one governance assignment projection."""
|
||||||
|
|
||||||
|
assignment_id: str
|
||||||
|
operation: GovernanceProjectionOperation
|
||||||
|
template: GovernanceTemplateMaterialization
|
||||||
|
provenance: Mapping[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.assignment_id or len(self.assignment_id) > 255:
|
||||||
|
raise ValueError("Governance projection assignment ids must contain at most 255 characters.")
|
||||||
|
if len(self.provenance) > 20:
|
||||||
|
raise ValueError("Governance projection provenance supports at most 20 entries.")
|
||||||
|
for key, value in self.provenance.items():
|
||||||
|
if not key or len(key) > 100 or len(value) > 500:
|
||||||
|
raise ValueError("Governance projection provenance entries exceed their bounds.")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class GovernanceProjectionBatch:
|
||||||
|
"""Versioned, bounded reconciliation request independent of Admin internals."""
|
||||||
|
|
||||||
|
operation_id: str
|
||||||
|
commands: tuple[GovernanceProjectionCommand, ...]
|
||||||
|
version: Literal["1"] = "1"
|
||||||
|
dry_run: bool = False
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.operation_id or len(self.operation_id) > 255:
|
||||||
|
raise ValueError("Governance projection operation ids must contain at most 255 characters.")
|
||||||
|
if not self.commands or len(self.commands) > 500:
|
||||||
|
raise ValueError("Governance projection batches must contain between 1 and 500 commands.")
|
||||||
|
assignment_ids = [command.assignment_id for command in self.commands]
|
||||||
|
if len(assignment_ids) != len(set(assignment_ids)):
|
||||||
|
raise ValueError("Governance projection assignment ids must be unique within a batch.")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class GovernanceProjectionOutcome:
|
||||||
|
assignment_id: str
|
||||||
|
template_id: str
|
||||||
|
tenant_id: str
|
||||||
|
kind: Literal["group", "role"]
|
||||||
|
operation: GovernanceProjectionOperation
|
||||||
|
status: GovernanceProjectionStatus
|
||||||
|
resource_id: str | None = None
|
||||||
|
blocker_codes: tuple[str, ...] = ()
|
||||||
|
message: str | None = None
|
||||||
|
provenance: Mapping[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class GovernanceProjectionResult:
|
||||||
|
operation_id: str
|
||||||
|
outcomes: tuple[GovernanceProjectionOutcome, ...]
|
||||||
|
version: Literal["1"] = "1"
|
||||||
|
dry_run: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def blocked(self) -> tuple[GovernanceProjectionOutcome, ...]:
|
||||||
|
return tuple(item for item in self.outcomes if item.status in {"blocked", "failed"})
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class AuditEvent:
|
class AuditEvent:
|
||||||
event_type: str
|
event_type: str
|
||||||
@@ -363,6 +483,7 @@ class AuditEvent:
|
|||||||
occurred_at: datetime | None = None
|
occurred_at: datetime | None = None
|
||||||
correlation_id: str | None = None
|
correlation_id: str | None = None
|
||||||
causation_id: str | None = None
|
causation_id: str | None = None
|
||||||
|
institutional_context: GovernedContextEnvelope | None = None
|
||||||
details: Mapping[str, object] = field(default_factory=dict)
|
details: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@@ -533,6 +654,18 @@ class AccessExplanationService(Protocol):
|
|||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class AccessExplanationSubjectPolicy(Protocol):
|
||||||
|
def decide_subject_selection(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: PrincipalRef,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> AccessExplanationSubjectDecision:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class TenantAccessProvisioner(Protocol):
|
class TenantAccessProvisioner(Protocol):
|
||||||
def ensure_default_roles(self, session: object, tenant: object | None = None) -> Mapping[str, object]:
|
def ensure_default_roles(self, session: object, tenant: object | None = None) -> Mapping[str, object]:
|
||||||
@@ -563,6 +696,25 @@ class TenantAccessProvisioner(Protocol):
|
|||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class FirstAdminProvisioner(Protocol):
|
||||||
|
"""Narrow Access boundary used only by the production bootstrap flow."""
|
||||||
|
|
||||||
|
def has_durable_system_administrator(self, session: object) -> bool:
|
||||||
|
...
|
||||||
|
|
||||||
|
def create_first_system_administrator(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant: object,
|
||||||
|
email: str,
|
||||||
|
display_name: str | None,
|
||||||
|
password: str,
|
||||||
|
) -> FirstSystemAdministratorRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class AccessAdministration(Protocol):
|
class AccessAdministration(Protocol):
|
||||||
def tenant_counts(self, session: object, tenant_id: str) -> Mapping[str, int]:
|
def tenant_counts(self, session: object, tenant_id: str) -> Mapping[str, int]:
|
||||||
@@ -619,6 +771,18 @@ class AccessGovernanceMaterializer(Protocol):
|
|||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class AccessGovernanceProjectionV1(Protocol):
|
||||||
|
"""Bulk reconciliation boundary for Admin-owned governance assignments."""
|
||||||
|
|
||||||
|
def reconcile(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
batch: GovernanceProjectionBatch,
|
||||||
|
) -> GovernanceProjectionResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class AuditSink(Protocol):
|
class AuditSink(Protocol):
|
||||||
def record(self, event: AuditEvent) -> None:
|
def record(self, event: AuditEvent) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import re
|
||||||
|
from typing import Any, Literal, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
AppearancePalette = Literal["default", "civic_blue", "forest", "plum"]
|
||||||
|
AppearanceSource = Literal["user", "tenant", "system", "tenant_lock", "system_lock"]
|
||||||
|
|
||||||
|
APPEARANCE_PALETTES: tuple[AppearancePalette, ...] = ("default", "civic_blue", "forest", "plum")
|
||||||
|
APPEARANCE_SETTINGS_KEY = "appearance"
|
||||||
|
APPEARANCE_OVERRIDE_SCHEMA_VERSION = "1"
|
||||||
|
APPEARANCE_OVERRIDE_TOKENS: tuple[str, ...] = (
|
||||||
|
"accent", "accent_foreground", "surface", "surface_foreground",
|
||||||
|
"success", "success_foreground", "info", "info_foreground",
|
||||||
|
"warning", "warning_foreground", "danger", "danger_foreground",
|
||||||
|
)
|
||||||
|
_STATUS_TOKENS = ("success", "info", "warning", "danger")
|
||||||
|
_HEX_COLOR = re.compile(r"^#[0-9a-fA-F]{6}$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class EffectiveAppearance:
|
||||||
|
palette: AppearancePalette
|
||||||
|
source: AppearanceSource
|
||||||
|
locked: bool
|
||||||
|
system_default_palette: AppearancePalette
|
||||||
|
tenant_default_palette: AppearancePalette | None
|
||||||
|
inherited_palette: AppearancePalette
|
||||||
|
custom_overrides: dict[str, object] | None = None
|
||||||
|
custom_overrides_allowed: bool = False
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"palette": self.palette,
|
||||||
|
"source": self.source,
|
||||||
|
"locked": self.locked,
|
||||||
|
"system_default_palette": self.system_default_palette,
|
||||||
|
"tenant_default_palette": self.tenant_default_palette,
|
||||||
|
"inherited_palette": self.inherited_palette,
|
||||||
|
"custom_overrides": self.custom_overrides,
|
||||||
|
"custom_overrides_allowed": self.custom_overrides_allowed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_appearance_palette(value: object, *, fallback: AppearancePalette | None = None) -> AppearancePalette | None:
|
||||||
|
normalized = str(value or "").strip().lower()
|
||||||
|
return normalized if normalized in APPEARANCE_PALETTES else fallback # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def appearance_settings(settings: Mapping[str, Any] | None) -> tuple[AppearancePalette | None, bool]:
|
||||||
|
raw = settings.get(APPEARANCE_SETTINGS_KEY) if isinstance(settings, Mapping) else None
|
||||||
|
if not isinstance(raw, Mapping):
|
||||||
|
return None, False
|
||||||
|
return normalize_appearance_palette(raw.get("default_palette")), raw.get("palette_locked") is True
|
||||||
|
|
||||||
|
|
||||||
|
def appearance_custom_overrides_policy(settings: Mapping[str, Any] | None) -> bool | None:
|
||||||
|
raw = settings.get(APPEARANCE_SETTINGS_KEY) if isinstance(settings, Mapping) else None
|
||||||
|
if not isinstance(raw, Mapping) or "allow_custom_overrides" not in raw:
|
||||||
|
return None
|
||||||
|
return raw.get("allow_custom_overrides") is True
|
||||||
|
|
||||||
|
|
||||||
|
def update_appearance_custom_overrides_policy(
|
||||||
|
settings: Mapping[str, Any] | None,
|
||||||
|
*,
|
||||||
|
allowed: bool | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
updated = dict(settings or {})
|
||||||
|
appearance = dict(updated.get(APPEARANCE_SETTINGS_KEY) or {}) if isinstance(updated.get(APPEARANCE_SETTINGS_KEY), Mapping) else {}
|
||||||
|
if allowed is None:
|
||||||
|
appearance.pop("allow_custom_overrides", None)
|
||||||
|
else:
|
||||||
|
appearance["allow_custom_overrides"] = allowed
|
||||||
|
if appearance:
|
||||||
|
updated[APPEARANCE_SETTINGS_KEY] = appearance
|
||||||
|
else:
|
||||||
|
updated.pop(APPEARANCE_SETTINGS_KEY, None)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_appearance_overrides(value: object) -> dict[str, object] | None:
|
||||||
|
"""Validate and canonicalize the versioned, all-or-nothing color contract."""
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise ValueError("Appearance overrides must be an object.")
|
||||||
|
if set(value) != {"schema_version", "light", "dark"}:
|
||||||
|
raise ValueError("Appearance overrides must contain only schema_version, light, and dark.")
|
||||||
|
if str(value.get("schema_version")) != APPEARANCE_OVERRIDE_SCHEMA_VERSION:
|
||||||
|
raise ValueError("Unsupported appearance override schema version.")
|
||||||
|
normalized: dict[str, object] = {"schema_version": APPEARANCE_OVERRIDE_SCHEMA_VERSION}
|
||||||
|
for mode in ("light", "dark"):
|
||||||
|
raw_mode = value.get(mode)
|
||||||
|
if not isinstance(raw_mode, Mapping) or set(raw_mode) != set(APPEARANCE_OVERRIDE_TOKENS):
|
||||||
|
raise ValueError(f"Appearance override mode {mode} must define every supported token exactly once.")
|
||||||
|
colors: dict[str, str] = {}
|
||||||
|
for token in APPEARANCE_OVERRIDE_TOKENS:
|
||||||
|
color = str(raw_mode.get(token) or "").strip().lower()
|
||||||
|
if not _HEX_COLOR.fullmatch(color):
|
||||||
|
raise ValueError(f"Appearance override {mode}.{token} must be a six-digit hexadecimal color.")
|
||||||
|
colors[token] = color
|
||||||
|
_validate_mode_accessibility(mode, colors)
|
||||||
|
normalized[mode] = colors
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_mode_accessibility(mode: str, colors: Mapping[str, str]) -> None:
|
||||||
|
pairs = (
|
||||||
|
("accent", "accent_foreground"), ("surface", "surface_foreground"),
|
||||||
|
("success", "success_foreground"), ("info", "info_foreground"),
|
||||||
|
("warning", "warning_foreground"), ("danger", "danger_foreground"),
|
||||||
|
)
|
||||||
|
for background, foreground in pairs:
|
||||||
|
if _contrast_ratio(colors[background], colors[foreground]) < 4.5:
|
||||||
|
raise ValueError(f"Appearance override {mode}.{foreground} must have WCAG AA contrast against {mode}.{background}.")
|
||||||
|
status_colors = [colors[token] for token in _STATUS_TOKENS]
|
||||||
|
for index, first in enumerate(status_colors):
|
||||||
|
for second in status_colors[index + 1:]:
|
||||||
|
if _rgb_distance(first, second) < 12:
|
||||||
|
raise ValueError(f"Appearance override status colors in {mode} must remain visibly distinct.")
|
||||||
|
|
||||||
|
|
||||||
|
def _relative_luminance(color: str) -> float:
|
||||||
|
channels = [int(color[index:index + 2], 16) / 255 for index in (1, 3, 5)]
|
||||||
|
linear = [channel / 12.92 if channel <= 0.04045 else ((channel + 0.055) / 1.055) ** 2.4 for channel in channels]
|
||||||
|
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
|
||||||
|
|
||||||
|
|
||||||
|
def _contrast_ratio(first: str, second: str) -> float:
|
||||||
|
high, low = sorted((_relative_luminance(first), _relative_luminance(second)), reverse=True)
|
||||||
|
return (high + 0.05) / (low + 0.05)
|
||||||
|
|
||||||
|
|
||||||
|
def _rgb_distance(first: str, second: str) -> float:
|
||||||
|
first_channels = [int(first[index:index + 2], 16) for index in (1, 3, 5)]
|
||||||
|
second_channels = [int(second[index:index + 2], 16) for index in (1, 3, 5)]
|
||||||
|
return sum((left - right) ** 2 for left, right in zip(first_channels, second_channels, strict=True)) ** 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def update_appearance_settings(
|
||||||
|
settings: Mapping[str, Any] | None,
|
||||||
|
*,
|
||||||
|
default_palette: AppearancePalette | None,
|
||||||
|
palette_locked: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
updated = dict(settings or {})
|
||||||
|
appearance = dict(updated.get(APPEARANCE_SETTINGS_KEY) or {}) if isinstance(updated.get(APPEARANCE_SETTINGS_KEY), Mapping) else {}
|
||||||
|
if default_palette is None:
|
||||||
|
appearance.pop("default_palette", None)
|
||||||
|
else:
|
||||||
|
normalized = normalize_appearance_palette(default_palette)
|
||||||
|
if normalized is None:
|
||||||
|
raise ValueError("Unsupported appearance palette.")
|
||||||
|
appearance["default_palette"] = normalized
|
||||||
|
if palette_locked:
|
||||||
|
appearance["palette_locked"] = True
|
||||||
|
else:
|
||||||
|
appearance.pop("palette_locked", None)
|
||||||
|
if appearance:
|
||||||
|
updated[APPEARANCE_SETTINGS_KEY] = appearance
|
||||||
|
else:
|
||||||
|
updated.pop(APPEARANCE_SETTINGS_KEY, None)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_effective_appearance(
|
||||||
|
*,
|
||||||
|
system_settings: Mapping[str, Any] | None,
|
||||||
|
tenant_settings: Mapping[str, Any] | None,
|
||||||
|
user_settings: Mapping[str, Any] | None,
|
||||||
|
) -> EffectiveAppearance:
|
||||||
|
system_palette, system_locked = appearance_settings(system_settings)
|
||||||
|
system_palette = system_palette or "default"
|
||||||
|
tenant_palette, tenant_locked = appearance_settings(tenant_settings)
|
||||||
|
inherited_palette = tenant_palette or system_palette
|
||||||
|
raw_ui = user_settings.get("ui") if isinstance(user_settings, Mapping) else None
|
||||||
|
user_palette = normalize_appearance_palette(raw_ui.get("palette")) if isinstance(raw_ui, Mapping) else None
|
||||||
|
system_custom_policy = appearance_custom_overrides_policy(system_settings) is True
|
||||||
|
tenant_custom_policy = appearance_custom_overrides_policy(tenant_settings)
|
||||||
|
custom_overrides_allowed = system_custom_policy and tenant_custom_policy is not False and not system_locked and not tenant_locked
|
||||||
|
try:
|
||||||
|
custom_overrides = normalize_appearance_overrides(raw_ui.get("appearance_overrides")) if isinstance(raw_ui, Mapping) else None
|
||||||
|
except ValueError:
|
||||||
|
custom_overrides = None
|
||||||
|
if not custom_overrides_allowed:
|
||||||
|
custom_overrides = None
|
||||||
|
|
||||||
|
if system_locked:
|
||||||
|
return EffectiveAppearance(system_palette, "system_lock", True, system_palette, tenant_palette, system_palette)
|
||||||
|
if tenant_locked:
|
||||||
|
return EffectiveAppearance(inherited_palette, "tenant_lock", True, system_palette, tenant_palette, inherited_palette)
|
||||||
|
return EffectiveAppearance(
|
||||||
|
user_palette or inherited_palette,
|
||||||
|
"user" if user_palette else "tenant" if tenant_palette else "system",
|
||||||
|
False,
|
||||||
|
system_palette,
|
||||||
|
tenant_palette,
|
||||||
|
inherited_palette,
|
||||||
|
custom_overrides,
|
||||||
|
custom_overrides_allowed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"APPEARANCE_PALETTES",
|
||||||
|
"APPEARANCE_SETTINGS_KEY",
|
||||||
|
"APPEARANCE_OVERRIDE_SCHEMA_VERSION",
|
||||||
|
"APPEARANCE_OVERRIDE_TOKENS",
|
||||||
|
"AppearancePalette",
|
||||||
|
"AppearanceSource",
|
||||||
|
"EffectiveAppearance",
|
||||||
|
"appearance_settings",
|
||||||
|
"appearance_custom_overrides_policy",
|
||||||
|
"normalize_appearance_overrides",
|
||||||
|
"normalize_appearance_palette",
|
||||||
|
"resolve_effective_appearance",
|
||||||
|
"update_appearance_settings",
|
||||||
|
"update_appearance_custom_overrides_policy",
|
||||||
|
]
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_APPLICATION_STATUS_PROJECTION = "application_status.projection"
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ApplicationStatusProjectionProvider(Protocol):
|
||||||
|
"""Bounded applicant-status access without exposing the owning module's data."""
|
||||||
|
|
||||||
|
def tenant_id_for_tracking_id(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tracking_id: str,
|
||||||
|
) -> str | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def public_access_challenge(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tracking_id: str,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_authenticated_projection(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tracking_id: str,
|
||||||
|
observed_at: datetime,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_public_projection(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tracking_id: str,
|
||||||
|
token: str | None,
|
||||||
|
observed_at: datetime,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def request_email_link(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tracking_id: str,
|
||||||
|
email: str,
|
||||||
|
requested_at: datetime,
|
||||||
|
) -> bool:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def application_status_projection_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> ApplicationStatusProjectionProvider | None:
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_APPLICATION_STATUS_PROJECTION):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_APPLICATION_STATUS_PROJECTION)
|
||||||
|
return (
|
||||||
|
capability
|
||||||
|
if isinstance(capability, ApplicationStatusProjectionProvider)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ApplicationStatusProjectionProvider",
|
||||||
|
"CAPABILITY_APPLICATION_STATUS_PROJECTION",
|
||||||
|
"application_status_projection_provider",
|
||||||
|
]
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_APPROVAL_REQUESTS = "approvals.requests"
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalCapabilityError(ValueError):
|
||||||
|
"""Stable error raised by Approval capability implementations."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ApprovalActorSelector:
|
||||||
|
kind: str
|
||||||
|
value: str
|
||||||
|
label: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ApprovalStepDefinition:
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
selectors: tuple[ApprovalActorSelector, ...]
|
||||||
|
required_approvals: int = 1
|
||||||
|
rejection_policy: str = "fail_fast"
|
||||||
|
due_at: datetime | None = None
|
||||||
|
signature_required: bool = False
|
||||||
|
forbidden_evidence_roles: tuple[str, ...] = ()
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ApprovalRequestCreateCommand:
|
||||||
|
title: str
|
||||||
|
subject_module: str
|
||||||
|
subject_type: str
|
||||||
|
subject_id: str
|
||||||
|
subject_version: str | None
|
||||||
|
subject_digest: str
|
||||||
|
steps: tuple[ApprovalStepDefinition, ...]
|
||||||
|
description: str | None = None
|
||||||
|
separation_of_duties: bool = True
|
||||||
|
unique_actors_across_steps: bool = False
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
policy_refs: tuple[str, ...] = ()
|
||||||
|
evidence_actors: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
|
||||||
|
template_id: str | None = None
|
||||||
|
template_revision: int | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ApprovalTemplateCreateCommand:
|
||||||
|
key: str
|
||||||
|
title: str
|
||||||
|
steps: tuple[ApprovalStepDefinition, ...]
|
||||||
|
description: str | None = None
|
||||||
|
separation_of_duties: bool = True
|
||||||
|
unique_actors_across_steps: bool = False
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ApprovalTemplateRef:
|
||||||
|
id: str
|
||||||
|
key: str
|
||||||
|
revision: int
|
||||||
|
state: str
|
||||||
|
content_sha256: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ApprovalDecisionCommand:
|
||||||
|
outcome: str
|
||||||
|
reason: str
|
||||||
|
expected_revision: int
|
||||||
|
idempotency_key: str
|
||||||
|
delegated_for_account_id: str | None = None
|
||||||
|
signature_ref: Mapping[str, object] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ApprovalRequestRef:
|
||||||
|
id: str
|
||||||
|
revision: int
|
||||||
|
state: str
|
||||||
|
current_step_key: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ApprovalDecisionReceipt:
|
||||||
|
request_id: str
|
||||||
|
revision: int
|
||||||
|
state: str
|
||||||
|
step_key: str
|
||||||
|
outcome: str
|
||||||
|
actor_id: str
|
||||||
|
recorded_at: datetime
|
||||||
|
receipt_sha256: str
|
||||||
|
authority_provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
replayed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ApprovalCheck:
|
||||||
|
request_id: str
|
||||||
|
revision: int
|
||||||
|
state: str
|
||||||
|
approved: bool
|
||||||
|
subject_module: str
|
||||||
|
subject_type: str
|
||||||
|
subject_id: str
|
||||||
|
subject_version: str | None
|
||||||
|
subject_digest: str
|
||||||
|
completed_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ApprovalRequestProvider(Protocol):
|
||||||
|
def create_template(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
command: ApprovalTemplateCreateCommand,
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> ApprovalTemplateRef: ...
|
||||||
|
|
||||||
|
def revise_template(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
template_id: str,
|
||||||
|
command: ApprovalTemplateCreateCommand,
|
||||||
|
expected_revision: int,
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> ApprovalTemplateRef: ...
|
||||||
|
|
||||||
|
def publish_template(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
template_id: str,
|
||||||
|
expected_revision: int,
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> ApprovalTemplateRef: ...
|
||||||
|
|
||||||
|
def create_request(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
command: ApprovalRequestCreateCommand,
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> ApprovalRequestRef: ...
|
||||||
|
|
||||||
|
def get_request(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request_id: str,
|
||||||
|
) -> Mapping[str, object] | None: ...
|
||||||
|
|
||||||
|
def decide(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request_id: str,
|
||||||
|
command: ApprovalDecisionCommand,
|
||||||
|
) -> ApprovalDecisionReceipt: ...
|
||||||
|
|
||||||
|
def check_approved(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request_id: str,
|
||||||
|
subject_module: str,
|
||||||
|
subject_type: str,
|
||||||
|
subject_id: str,
|
||||||
|
subject_version: str | None,
|
||||||
|
subject_digest: str,
|
||||||
|
) -> ApprovalCheck: ...
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ApprovalActorSelector",
|
||||||
|
"ApprovalCapabilityError",
|
||||||
|
"ApprovalCheck",
|
||||||
|
"ApprovalDecisionCommand",
|
||||||
|
"ApprovalDecisionReceipt",
|
||||||
|
"ApprovalRequestCreateCommand",
|
||||||
|
"ApprovalRequestProvider",
|
||||||
|
"ApprovalRequestRef",
|
||||||
|
"ApprovalStepDefinition",
|
||||||
|
"ApprovalTemplateCreateCommand",
|
||||||
|
"ApprovalTemplateRef",
|
||||||
|
"CAPABILITY_APPROVAL_REQUESTS",
|
||||||
|
]
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.institutional import GovernedContextEnvelope
|
||||||
|
|
||||||
|
AutomationInvocationKind = Literal[
|
||||||
|
"manual",
|
||||||
|
"api",
|
||||||
|
"schedule",
|
||||||
|
"event",
|
||||||
|
"workflow",
|
||||||
|
"dependency",
|
||||||
|
"retry",
|
||||||
|
"backfill",
|
||||||
|
]
|
||||||
|
AutomationSubjectKind = Literal["delegated_user", "service_account"]
|
||||||
|
AUTOMATION_PRINCIPAL_CONTRACT_VERSION = "1"
|
||||||
|
ACTION_EFFECT_CONTRACT_VERSION = "1"
|
||||||
|
|
||||||
|
ActionRiskLevel = Literal["low", "moderate", "high", "critical"]
|
||||||
|
ActionReversibility = Literal[
|
||||||
|
"reversible",
|
||||||
|
"compensatable",
|
||||||
|
"corrective_only",
|
||||||
|
"irreversible",
|
||||||
|
]
|
||||||
|
ActionRecoveryMode = Literal[
|
||||||
|
"atomic",
|
||||||
|
"compensation",
|
||||||
|
"snapshot_restore",
|
||||||
|
"forward_recovery",
|
||||||
|
"irreversible",
|
||||||
|
]
|
||||||
|
ActionExecutionState = Literal[
|
||||||
|
"pending",
|
||||||
|
"running",
|
||||||
|
"completed",
|
||||||
|
"blocked",
|
||||||
|
"retryable",
|
||||||
|
"quarantined",
|
||||||
|
"manual_required",
|
||||||
|
"compensation_required",
|
||||||
|
]
|
||||||
|
EffectOperation = Literal[
|
||||||
|
"created",
|
||||||
|
"changed",
|
||||||
|
"deleted",
|
||||||
|
"sent",
|
||||||
|
"notified",
|
||||||
|
"locked",
|
||||||
|
"retained",
|
||||||
|
"external",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class EffectDefinition:
|
||||||
|
effect_key: str
|
||||||
|
owner_module: str
|
||||||
|
operation: EffectOperation
|
||||||
|
description: str
|
||||||
|
resource_types: tuple[str, ...] = ()
|
||||||
|
visibility_classification: str = "internal"
|
||||||
|
audit_event_types: tuple[str, ...] = ()
|
||||||
|
compensation_hint: str | None = None
|
||||||
|
contract_version: str = ACTION_EFFECT_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract_version(self.contract_version)
|
||||||
|
_require_text(self.effect_key, "Effect key")
|
||||||
|
_require_text(self.owner_module, "Effect owner module")
|
||||||
|
_require_text(self.description, "Effect description")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ActionDefinition:
|
||||||
|
action_key: str
|
||||||
|
owner_module: str
|
||||||
|
description: str
|
||||||
|
input_schema_ref: str
|
||||||
|
required_scopes: tuple[str, ...] = ()
|
||||||
|
required_capabilities: tuple[str, ...] = ()
|
||||||
|
policy_checks: tuple[str, ...] = ()
|
||||||
|
risk_level: ActionRiskLevel = "moderate"
|
||||||
|
reversibility: ActionReversibility = "corrective_only"
|
||||||
|
expected_effect_keys: tuple[str, ...] = ()
|
||||||
|
idempotency_strategy: str = "caller_supplied"
|
||||||
|
audit_event_types: tuple[str, ...] = ()
|
||||||
|
preview_required: bool = True
|
||||||
|
recovery_mode: ActionRecoveryMode = "forward_recovery"
|
||||||
|
recovery_verification: tuple[str, ...] = (
|
||||||
|
"verify the provider result and every announced effect before continuation",
|
||||||
|
)
|
||||||
|
contract_version: str = ACTION_EFFECT_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract_version(self.contract_version)
|
||||||
|
_require_text(self.action_key, "Action key")
|
||||||
|
_require_text(self.owner_module, "Action owner module")
|
||||||
|
_require_text(self.description, "Action description")
|
||||||
|
_require_text(self.input_schema_ref, "Action input schema reference")
|
||||||
|
_require_text(self.idempotency_strategy, "Action idempotency strategy")
|
||||||
|
if self.recovery_mode not in {
|
||||||
|
"atomic",
|
||||||
|
"compensation",
|
||||||
|
"snapshot_restore",
|
||||||
|
"forward_recovery",
|
||||||
|
"irreversible",
|
||||||
|
}:
|
||||||
|
raise ValueError("Action recovery mode is not supported")
|
||||||
|
if any(not value.strip() for value in self.required_scopes):
|
||||||
|
raise ValueError("Action scopes must not be empty")
|
||||||
|
if any(not value.strip() for value in self.required_capabilities):
|
||||||
|
raise ValueError("Action capabilities must not be empty")
|
||||||
|
if any(not value.strip() for value in self.expected_effect_keys):
|
||||||
|
raise ValueError("Expected effect keys must not be empty")
|
||||||
|
if not self.recovery_verification or any(
|
||||||
|
not value.strip() for value in self.recovery_verification
|
||||||
|
):
|
||||||
|
raise ValueError("Actions must declare recovery verification steps")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ActionExecutionRequest:
|
||||||
|
tenant_id: str
|
||||||
|
action_key: str
|
||||||
|
input: Mapping[str, object]
|
||||||
|
idempotency_key: str
|
||||||
|
invocation: AutomationInvocation
|
||||||
|
institutional_context: GovernedContextEnvelope | None = None
|
||||||
|
actor_ref: str | None = None
|
||||||
|
preview_ref: str | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
contract_version: str = ACTION_EFFECT_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract_version(self.contract_version)
|
||||||
|
_require_text(self.tenant_id, "Action tenant id")
|
||||||
|
_require_text(self.action_key, "Action key")
|
||||||
|
_require_text(self.idempotency_key, "Action idempotency key")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class EffectPreview:
|
||||||
|
effect_key: str
|
||||||
|
summary: str
|
||||||
|
resource_refs: tuple[str, ...] = ()
|
||||||
|
external_system_refs: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ActionPreview:
|
||||||
|
action_key: str
|
||||||
|
allowed: bool
|
||||||
|
summary: str
|
||||||
|
risk_level: ActionRiskLevel
|
||||||
|
reversibility: ActionReversibility
|
||||||
|
effects: tuple[EffectPreview, ...] = ()
|
||||||
|
blockers: tuple[str, ...] = ()
|
||||||
|
policy_provenance: tuple[Mapping[str, object], ...] = ()
|
||||||
|
preview_ref: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ObservedEffect:
|
||||||
|
effect_key: str
|
||||||
|
operation: EffectOperation
|
||||||
|
resource_ref: str | None = None
|
||||||
|
external_system_ref: str | None = None
|
||||||
|
audit_event_ref: str | None = None
|
||||||
|
summary: str | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ActionExecutionResult:
|
||||||
|
state: ActionExecutionState
|
||||||
|
output: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
observed_effects: tuple[ObservedEffect, ...] = ()
|
||||||
|
error: str | None = None
|
||||||
|
retry_after: datetime | None = None
|
||||||
|
manual_instructions: str | None = None
|
||||||
|
compensation_action_key: str | None = None
|
||||||
|
audit_event_refs: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ActionEffectProvider(Protocol):
|
||||||
|
def action_definitions(self) -> tuple[ActionDefinition, ...]: ...
|
||||||
|
|
||||||
|
def effect_definitions(self) -> tuple[EffectDefinition, ...]: ...
|
||||||
|
|
||||||
|
def preview_action(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ActionExecutionRequest,
|
||||||
|
) -> ActionPreview: ...
|
||||||
|
|
||||||
|
def execute_action(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ActionExecutionRequest,
|
||||||
|
) -> ActionExecutionResult: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AutomationInvocation:
|
||||||
|
kind: AutomationInvocationKind = "manual"
|
||||||
|
trigger_ref: str | None = None
|
||||||
|
delivery_ref: str | None = None
|
||||||
|
event_id: str | None = None
|
||||||
|
event_type: str | None = None
|
||||||
|
correlation_id: str | None = None
|
||||||
|
causation_id: str | None = None
|
||||||
|
scheduled_for: datetime | None = None
|
||||||
|
requested_by: str | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AutomationPrincipalRequest:
|
||||||
|
tenant_id: str
|
||||||
|
authorization_ref: str
|
||||||
|
grant_scopes: tuple[str, ...]
|
||||||
|
account_id: str | None = None
|
||||||
|
membership_id: str | None = None
|
||||||
|
service_account_id: str | None = None
|
||||||
|
subject_kind: AutomationSubjectKind = "delegated_user"
|
||||||
|
context: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
contract_version: str = AUTOMATION_PRINCIPAL_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if (
|
||||||
|
self.contract_version
|
||||||
|
!= AUTOMATION_PRINCIPAL_CONTRACT_VERSION
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Unsupported automation-principal contract version"
|
||||||
|
)
|
||||||
|
if not self.tenant_id.strip():
|
||||||
|
raise ValueError("Automation tenant id is required")
|
||||||
|
if not self.authorization_ref.strip():
|
||||||
|
raise ValueError(
|
||||||
|
"Automation authorization artifact reference is required"
|
||||||
|
)
|
||||||
|
if self.subject_kind == "delegated_user":
|
||||||
|
if (
|
||||||
|
not self.account_id
|
||||||
|
or not self.membership_id
|
||||||
|
or self.service_account_id is not None
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Delegated-user automation requires account and "
|
||||||
|
"membership references only"
|
||||||
|
)
|
||||||
|
elif (
|
||||||
|
not self.service_account_id
|
||||||
|
or self.account_id is not None
|
||||||
|
or self.membership_id is not None
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Service-account automation requires only a service-account "
|
||||||
|
"reference"
|
||||||
|
)
|
||||||
|
if any(
|
||||||
|
not scope.strip()
|
||||||
|
for scope in self.grant_scopes
|
||||||
|
):
|
||||||
|
raise ValueError("Automation grant scopes must not be empty")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def delegated_user(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
account_id: str,
|
||||||
|
membership_id: str,
|
||||||
|
authorization_ref: str,
|
||||||
|
grant_scopes: tuple[str, ...],
|
||||||
|
context: Mapping[str, object] | None = None,
|
||||||
|
) -> AutomationPrincipalRequest:
|
||||||
|
return cls(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
account_id=account_id,
|
||||||
|
membership_id=membership_id,
|
||||||
|
authorization_ref=authorization_ref,
|
||||||
|
grant_scopes=grant_scopes,
|
||||||
|
context=context or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def service_account(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
service_account_id: str,
|
||||||
|
authorization_ref: str,
|
||||||
|
grant_scopes: tuple[str, ...],
|
||||||
|
context: Mapping[str, object] | None = None,
|
||||||
|
) -> AutomationPrincipalRequest:
|
||||||
|
return cls(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
service_account_id=service_account_id,
|
||||||
|
subject_kind="service_account",
|
||||||
|
authorization_ref=authorization_ref,
|
||||||
|
grant_scopes=grant_scopes,
|
||||||
|
context=context or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AutomationPrincipalResolution:
|
||||||
|
allowed: bool
|
||||||
|
principal: object | None = None
|
||||||
|
reason: str | None = None
|
||||||
|
granted_scopes: tuple[str, ...] = ()
|
||||||
|
missing_scopes: tuple[str, ...] = ()
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class AutomationPrincipalProvider(Protocol):
|
||||||
|
def resolve_automation_principal(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
request: AutomationPrincipalRequest,
|
||||||
|
) -> AutomationPrincipalResolution:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def automation_principal_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> AutomationPrincipalProvider | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not registry.has_capability(
|
||||||
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(
|
||||||
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
capability
|
||||||
|
if isinstance(capability, AutomationPrincipalProvider)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def action_effect_provider(
|
||||||
|
registry: object | None,
|
||||||
|
capability_name: str,
|
||||||
|
) -> ActionEffectProvider | None:
|
||||||
|
name = capability_name.strip()
|
||||||
|
if (
|
||||||
|
not name
|
||||||
|
or registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not registry.has_capability(name)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(name)
|
||||||
|
return (
|
||||||
|
capability
|
||||||
|
if isinstance(capability, ActionEffectProvider)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_contract_version(value: str) -> None:
|
||||||
|
if value != ACTION_EFFECT_CONTRACT_VERSION:
|
||||||
|
raise ValueError("Unsupported action/effect contract version")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_text(value: str, label: str) -> None:
|
||||||
|
if not value.strip():
|
||||||
|
raise ValueError(f"{label} is required")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ACTION_EFFECT_CONTRACT_VERSION",
|
||||||
|
"CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER",
|
||||||
|
"AUTOMATION_PRINCIPAL_CONTRACT_VERSION",
|
||||||
|
"ActionDefinition",
|
||||||
|
"ActionEffectProvider",
|
||||||
|
"ActionExecutionRequest",
|
||||||
|
"ActionExecutionResult",
|
||||||
|
"ActionExecutionState",
|
||||||
|
"AutomationInvocation",
|
||||||
|
"AutomationInvocationKind",
|
||||||
|
"AutomationPrincipalProvider",
|
||||||
|
"AutomationPrincipalRequest",
|
||||||
|
"AutomationPrincipalResolution",
|
||||||
|
"AutomationSubjectKind",
|
||||||
|
"ActionPreview",
|
||||||
|
"ActionRecoveryMode",
|
||||||
|
"ActionReversibility",
|
||||||
|
"ActionRiskLevel",
|
||||||
|
"EffectDefinition",
|
||||||
|
"EffectOperation",
|
||||||
|
"EffectPreview",
|
||||||
|
"ObservedEffect",
|
||||||
|
"action_effect_provider",
|
||||||
|
"automation_principal_provider",
|
||||||
|
]
|
||||||
@@ -8,6 +8,8 @@ from typing import Protocol, runtime_checkable
|
|||||||
|
|
||||||
CAPABILITY_CALENDAR_SCHEDULING = "calendar.scheduling"
|
CAPABILITY_CALENDAR_SCHEDULING = "calendar.scheduling"
|
||||||
CAPABILITY_CALENDAR_OUTBOX = "calendar.outbox"
|
CAPABILITY_CALENDAR_OUTBOX = "calendar.outbox"
|
||||||
|
CAPABILITY_CALENDAR_INVITATIONS = "calendar.invitations"
|
||||||
|
CAPABILITY_CALENDAR_EXTERNAL_PROFILES = "calendar.externalProfiles"
|
||||||
CALENDAR_AVAILABILITY_READ_SCOPE = "calendar:availability:read"
|
CALENDAR_AVAILABILITY_READ_SCOPE = "calendar:availability:read"
|
||||||
CALENDAR_EVENT_WRITE_SCOPE = "calendar:event:write"
|
CALENDAR_EVENT_WRITE_SCOPE = "calendar:event:write"
|
||||||
|
|
||||||
@@ -43,6 +45,103 @@ class CalendarEventRef:
|
|||||||
outbox_operation_id: str | None = None
|
outbox_operation_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CalendarEventReleaseRef:
|
||||||
|
event_id: str
|
||||||
|
accepted: bool = True
|
||||||
|
already_released: bool = False
|
||||||
|
external_state: str = "local_released"
|
||||||
|
outbox_operation_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CalendarInvitationAttendeeRequest:
|
||||||
|
address: str
|
||||||
|
name: str | None = None
|
||||||
|
role: str = "REQ-PARTICIPANT"
|
||||||
|
participation_status: str = "NEEDS-ACTION"
|
||||||
|
rsvp: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CalendarInvitationRequest:
|
||||||
|
correlation_id: str
|
||||||
|
source_module: str
|
||||||
|
source_resource_type: str
|
||||||
|
source_resource_id: str | None
|
||||||
|
summary: str
|
||||||
|
start_at: datetime
|
||||||
|
attendees: tuple[CalendarInvitationAttendeeRequest, ...]
|
||||||
|
calendar_id: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
location: str | None = None
|
||||||
|
end_at: datetime | None = None
|
||||||
|
timezone: str | None = None
|
||||||
|
organizer: Mapping[str, object] | None = None
|
||||||
|
classification: str = "PUBLIC"
|
||||||
|
categories: tuple[str, ...] = ()
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CalendarInvitationRef:
|
||||||
|
event_id: str
|
||||||
|
calendar_id: str
|
||||||
|
uid: str
|
||||||
|
correlation_id: str
|
||||||
|
source_module: str
|
||||||
|
source_resource_type: str
|
||||||
|
source_resource_id: str | None
|
||||||
|
attendees: tuple[Mapping[str, object], ...] = ()
|
||||||
|
external_state: str = "local"
|
||||||
|
outbox_operation_id: str | None = None
|
||||||
|
reply_ingress: str = "capability"
|
||||||
|
recurrence_supported: bool = False
|
||||||
|
degraded_reasons: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CalendarInvitationCalendarRef:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
color: str | None = None
|
||||||
|
timezone: str = "UTC"
|
||||||
|
source_kind: str = "local"
|
||||||
|
writable: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CalendarExternalProfileRequest:
|
||||||
|
"""Connector-neutral request for a Calendar-owned external profile."""
|
||||||
|
|
||||||
|
profile_kind: str
|
||||||
|
calendar_id: str
|
||||||
|
endpoint_url: str
|
||||||
|
display_name: str | None = None
|
||||||
|
auth_type: str = "none"
|
||||||
|
username: str | None = None
|
||||||
|
credential_ref: str | None = None
|
||||||
|
sync_enabled: bool = True
|
||||||
|
sync_interval_seconds: int = 900
|
||||||
|
sync_direction: str = "two_way"
|
||||||
|
conflict_policy: str = "etag"
|
||||||
|
connector_profile_ref: str | None = None
|
||||||
|
identity_mapping_ref: str | None = None
|
||||||
|
resource_calendar_ref: str | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CalendarExternalProfileRef:
|
||||||
|
source_id: str
|
||||||
|
calendar_id: str
|
||||||
|
profile_kind: str
|
||||||
|
transport_kind: str
|
||||||
|
connector_profile_ref: str | None = None
|
||||||
|
identity_mapping_ref: str | None = None
|
||||||
|
resource_calendar_ref: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class CalendarSchedulingProvider(Protocol):
|
class CalendarSchedulingProvider(Protocol):
|
||||||
def list_freebusy(
|
def list_freebusy(
|
||||||
@@ -66,6 +165,27 @@ class CalendarSchedulingProvider(Protocol):
|
|||||||
) -> CalendarEventRef:
|
) -> CalendarEventRef:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def promote_event(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str | None,
|
||||||
|
event_id: str,
|
||||||
|
request: CalendarEventRequest,
|
||||||
|
) -> CalendarEventRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def release_event(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str | None,
|
||||||
|
event_id: str,
|
||||||
|
) -> CalendarEventReleaseRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class CalendarOutboxProvider(Protocol):
|
class CalendarOutboxProvider(Protocol):
|
||||||
@@ -80,6 +200,108 @@ class CalendarOutboxProvider(Protocol):
|
|||||||
) -> Mapping[str, object]:
|
) -> Mapping[str, object]:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class CalendarInvitationProvider(Protocol):
|
||||||
|
"""Correlation-aware invitation boundary for Campaign and Mail adapters."""
|
||||||
|
|
||||||
|
def list_calendars(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str | None = None,
|
||||||
|
group_ids: Sequence[str] = (),
|
||||||
|
can_admin: bool = False,
|
||||||
|
) -> Sequence[CalendarInvitationCalendarRef]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def render_invitation(self, request: CalendarInvitationRequest) -> str:
|
||||||
|
...
|
||||||
|
|
||||||
|
def upsert_invitation(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str | None,
|
||||||
|
request: CalendarInvitationRequest,
|
||||||
|
) -> CalendarInvitationRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_invitation(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
correlation_id: str,
|
||||||
|
) -> CalendarInvitationRef | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_invitations(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
correlation_ids: Sequence[str],
|
||||||
|
) -> Mapping[str, CalendarInvitationRef]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def summarize_invitations(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
source_module: str,
|
||||||
|
source_resource_type: str,
|
||||||
|
source_resource_id: str | None,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def record_response(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
attendee_address: str,
|
||||||
|
participation_status: str,
|
||||||
|
correlation_id: str | None = None,
|
||||||
|
uid: str | None = None,
|
||||||
|
responded_at: datetime | None = None,
|
||||||
|
evidence: Mapping[str, object] | None = None,
|
||||||
|
) -> CalendarInvitationRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def record_icalendar_reply(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
icalendar: str,
|
||||||
|
received_at: datetime | None = None,
|
||||||
|
evidence: Mapping[str, object] | None = None,
|
||||||
|
) -> Sequence[CalendarInvitationRef]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class CalendarExternalProfileProvider(Protocol):
|
||||||
|
"""Optional connector route for Calendar-owned groupware adapters."""
|
||||||
|
|
||||||
|
def supported_profiles(self) -> Sequence[Mapping[str, object]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def configure_profile(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str | None,
|
||||||
|
request: CalendarExternalProfileRequest,
|
||||||
|
) -> CalendarExternalProfileRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
def calendar_scheduling_provider(registry: object | None) -> CalendarSchedulingProvider | None:
|
def calendar_scheduling_provider(registry: object | None) -> CalendarSchedulingProvider | None:
|
||||||
if registry is None or not hasattr(registry, "has_capability"):
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
return None
|
return None
|
||||||
@@ -96,3 +318,29 @@ def calendar_outbox_provider(registry: object | None) -> CalendarOutboxProvider
|
|||||||
return None
|
return None
|
||||||
capability = registry.capability(CAPABILITY_CALENDAR_OUTBOX)
|
capability = registry.capability(CAPABILITY_CALENDAR_OUTBOX)
|
||||||
return capability if isinstance(capability, CalendarOutboxProvider) else None
|
return capability if isinstance(capability, CalendarOutboxProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def calendar_invitation_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> CalendarInvitationProvider | None:
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_CALENDAR_INVITATIONS):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_CALENDAR_INVITATIONS)
|
||||||
|
return capability if isinstance(capability, CalendarInvitationProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def calendar_external_profile_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> CalendarExternalProfileProvider | None:
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_CALENDAR_EXTERNAL_PROFILES):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_CALENDAR_EXTERNAL_PROFILES)
|
||||||
|
return (
|
||||||
|
capability
|
||||||
|
if isinstance(capability, CalendarExternalProfileProvider)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,14 +3,29 @@ from __future__ import annotations
|
|||||||
from collections.abc import Callable, Iterable, Mapping
|
from collections.abc import Callable, Iterable, Mapping
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Protocol, runtime_checkable
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT = "campaigns.mailPolicyContext"
|
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT = "campaigns.mailPolicyContext"
|
||||||
CAPABILITY_CAMPAIGNS_ACCESS = "campaigns.access"
|
CAPABILITY_CAMPAIGNS_ACCESS = "campaigns.access"
|
||||||
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT = "campaigns.policyContext"
|
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT = "campaigns.policyContext"
|
||||||
CAPABILITY_CAMPAIGNS_DELIVERY_TASKS = "campaigns.deliveryTasks"
|
CAPABILITY_CAMPAIGNS_DELIVERY_TASKS = "campaigns.deliveryTasks"
|
||||||
|
CAPABILITY_CAMPAIGNS_SCHEDULES = "campaigns.schedules"
|
||||||
CAPABILITY_CAMPAIGNS_RETENTION = "campaigns.retention"
|
CAPABILITY_CAMPAIGNS_RETENTION = "campaigns.retention"
|
||||||
|
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION = "campaigns.workOrchestration"
|
||||||
|
|
||||||
|
CampaignWorkAssigneeKind = Literal[
|
||||||
|
"account",
|
||||||
|
"group",
|
||||||
|
"organization_function",
|
||||||
|
]
|
||||||
|
CampaignWorkHandoffStatus = Literal[
|
||||||
|
"open",
|
||||||
|
"in_progress",
|
||||||
|
"completed",
|
||||||
|
"rejected",
|
||||||
|
"cancelled",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -31,6 +46,88 @@ class CampaignPolicyContext:
|
|||||||
settings: Mapping[str, object] = field(default_factory=dict)
|
settings: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CampaignWorkHandoffRequest:
|
||||||
|
"""Typed request used by Workflow to open accountable Campaign work."""
|
||||||
|
|
||||||
|
tenant_id: str
|
||||||
|
idempotency_key: str
|
||||||
|
purpose: str
|
||||||
|
assignee_kind: CampaignWorkAssigneeKind
|
||||||
|
assignee_id: str
|
||||||
|
campaign_id: str | None = None
|
||||||
|
create_external_id: str | None = None
|
||||||
|
create_name: str | None = None
|
||||||
|
create_description: str | None = None
|
||||||
|
expected_campaign_revision: int | None = None
|
||||||
|
due_at: datetime | None = None
|
||||||
|
mirror_to_tasks: bool = True
|
||||||
|
correlation_id: str | None = None
|
||||||
|
workflow_instance_id: str | None = None
|
||||||
|
workflow_step_id: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for value, label in (
|
||||||
|
(self.tenant_id, "Campaign hand-off tenant"),
|
||||||
|
(self.idempotency_key, "Campaign hand-off idempotency key"),
|
||||||
|
(self.purpose, "Campaign hand-off purpose"),
|
||||||
|
(self.assignee_id, "Campaign hand-off assignee"),
|
||||||
|
):
|
||||||
|
if not value.strip():
|
||||||
|
raise ValueError(f"{label} is required")
|
||||||
|
references_existing = bool(self.campaign_id and self.campaign_id.strip())
|
||||||
|
creates_new = bool(
|
||||||
|
self.create_external_id
|
||||||
|
and self.create_external_id.strip()
|
||||||
|
and self.create_name
|
||||||
|
and self.create_name.strip()
|
||||||
|
)
|
||||||
|
if references_existing == creates_new:
|
||||||
|
raise ValueError(
|
||||||
|
"Campaign hand-offs must either reference one campaign or "
|
||||||
|
"declare one new campaign."
|
||||||
|
)
|
||||||
|
if self.expected_campaign_revision is not None and (
|
||||||
|
self.expected_campaign_revision < 1
|
||||||
|
):
|
||||||
|
raise ValueError("Expected Campaign revisions start at one")
|
||||||
|
if self.due_at is not None and self.due_at.tzinfo is None:
|
||||||
|
raise ValueError("Campaign hand-off due dates require a timezone")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CampaignWorkHandoffRef:
|
||||||
|
"""Stable, revision-bearing reference returned to the Workflow instance."""
|
||||||
|
|
||||||
|
tenant_id: str
|
||||||
|
campaign_id: str
|
||||||
|
campaign_version_id: str
|
||||||
|
campaign_revision: int
|
||||||
|
assignment_id: str
|
||||||
|
assignment_revision: int
|
||||||
|
status: CampaignWorkHandoffStatus
|
||||||
|
action_url: str
|
||||||
|
campaign_ref: str
|
||||||
|
assignment_ref: str
|
||||||
|
event_type: str = "campaign.work.changed"
|
||||||
|
replayed: bool = False
|
||||||
|
optional_capabilities: Mapping[str, bool] = field(default_factory=dict)
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CampaignWorkHandoffInspection:
|
||||||
|
"""Current authorization and revision check before Workflow continuation."""
|
||||||
|
|
||||||
|
allowed: bool
|
||||||
|
status: CampaignWorkHandoffStatus | None = None
|
||||||
|
assignment_revision: int | None = None
|
||||||
|
action_url: str | None = None
|
||||||
|
assignment_ref: str | None = None
|
||||||
|
reason: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class CampaignMailPolicyContextProvider(Protocol):
|
class CampaignMailPolicyContextProvider(Protocol):
|
||||||
def get_campaign_mail_policy_context(
|
def get_campaign_mail_policy_context(
|
||||||
@@ -95,6 +192,9 @@ class CampaignPolicyContextProvider(Protocol):
|
|||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class CampaignDeliveryTaskProvider(Protocol):
|
class CampaignDeliveryTaskProvider(Protocol):
|
||||||
|
def tenant_id_for_job(self, session: object, *, job_id: str) -> str | None:
|
||||||
|
...
|
||||||
|
|
||||||
def send_campaign_job(self, session: object, *, job_id: str, enqueue_imap_task: bool = True) -> Mapping[str, object]:
|
def send_campaign_job(self, session: object, *, job_id: str, enqueue_imap_task: bool = True) -> Mapping[str, object]:
|
||||||
...
|
...
|
||||||
|
|
||||||
@@ -102,6 +202,21 @@ class CampaignDeliveryTaskProvider(Protocol):
|
|||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class CampaignScheduleProvider(Protocol):
|
||||||
|
"""Durable boundary for due manual drafts and governed autonomous occurrences."""
|
||||||
|
|
||||||
|
def dispatch_due(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class CampaignRetentionProvider(Protocol):
|
class CampaignRetentionProvider(Protocol):
|
||||||
def apply_retention(
|
def apply_retention(
|
||||||
@@ -113,3 +228,45 @@ class CampaignRetentionProvider(Protocol):
|
|||||||
policy_for_campaign_id: Callable[[str | None], object],
|
policy_for_campaign_id: Callable[[str | None], object],
|
||||||
) -> Mapping[str, Mapping[str, int]]:
|
) -> Mapping[str, Mapping[str, int]]:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class CampaignWorkOrchestrationProvider(Protocol):
|
||||||
|
"""Optional Campaign boundary for durable Workflow-owned hand-offs."""
|
||||||
|
|
||||||
|
def prepare_handoff(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: CampaignWorkHandoffRequest,
|
||||||
|
) -> CampaignWorkHandoffRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def inspect_handoff(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
assignment_id: str,
|
||||||
|
expected_revision: int | None = None,
|
||||||
|
) -> CampaignWorkHandoffInspection:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def campaign_work_orchestration_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> CampaignWorkOrchestrationProvider | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION)
|
||||||
|
return (
|
||||||
|
capability
|
||||||
|
if isinstance(capability, CampaignWorkOrchestrationProvider)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from typing import Any, Iterable, Sequence
|
|||||||
from sqlalchemy import BigInteger, DateTime, Index, Integer, JSON, String, UniqueConstraint, func
|
from sqlalchemy import BigInteger, DateTime, Index, Integer, JSON, String, UniqueConstraint, func
|
||||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||||
|
|
||||||
from govoplan_core.core.events import EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, publish_platform_event
|
from govoplan_core.core.events import EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, emit_platform_event
|
||||||
from govoplan_core.db.base import Base, utcnow
|
from govoplan_core.db.base import Base, utcnow
|
||||||
|
|
||||||
WATERMARK_PREFIX = "seq:"
|
WATERMARK_PREFIX = "seq:"
|
||||||
@@ -96,13 +96,14 @@ def record_change(
|
|||||||
payload=payload or {},
|
payload=payload or {},
|
||||||
)
|
)
|
||||||
session.add(entry)
|
session.add(entry)
|
||||||
_publish_change_event(entry)
|
_publish_change_event(session, entry)
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
|
|
||||||
def _publish_change_event(entry: ChangeSequenceEntry) -> None:
|
def _publish_change_event(session: Session, entry: ChangeSequenceEntry) -> None:
|
||||||
event_type = _change_event_type(entry.module_id, entry.resource_type, entry.operation)
|
event_type = _change_event_type(entry.module_id, entry.resource_type, entry.operation)
|
||||||
publish_platform_event(
|
emit_platform_event(
|
||||||
|
session,
|
||||||
PlatformEvent(
|
PlatformEvent(
|
||||||
type=event_type,
|
type=event_type,
|
||||||
module_id=entry.module_id,
|
module_id=entry.module_id,
|
||||||
|
|||||||
@@ -0,0 +1,559 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Callable, Iterable, Mapping, Sequence
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
||||||
|
class ConcurrencyError(RuntimeError):
|
||||||
|
"""Base class for mutation precondition and compare-and-set failures."""
|
||||||
|
|
||||||
|
|
||||||
|
class MissingPreconditionError(ConcurrencyError):
|
||||||
|
def __init__(self, *, resource_type: str, resource_id: str) -> None:
|
||||||
|
self.resource_type = resource_type
|
||||||
|
self.resource_id = resource_id
|
||||||
|
super().__init__("A strong If-Match precondition is required for this mutation.")
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"code": "precondition_required",
|
||||||
|
"resource": {
|
||||||
|
"type": self.resource_type,
|
||||||
|
"id": self.resource_id,
|
||||||
|
},
|
||||||
|
"retryable": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RevisionConflictError(ConcurrencyError):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
current_revision: int,
|
||||||
|
submitted_base_revision: int,
|
||||||
|
refresh_path: str | None = None,
|
||||||
|
conflicts: Sequence["MergeConflict"] = (),
|
||||||
|
merge_candidate: Any = None,
|
||||||
|
current_etag: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.resource_type = resource_type
|
||||||
|
self.resource_id = resource_id
|
||||||
|
self.current_revision = int(current_revision)
|
||||||
|
self.submitted_base_revision = int(submitted_base_revision)
|
||||||
|
self.refresh_path = refresh_path
|
||||||
|
self.conflicts = tuple(conflicts)
|
||||||
|
self.merge_candidate = merge_candidate
|
||||||
|
self.current_etag = current_etag
|
||||||
|
super().__init__(
|
||||||
|
f"{resource_type} {resource_id} changed from revision "
|
||||||
|
f"{submitted_base_revision} to {current_revision}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def safe_merge_available(self) -> bool:
|
||||||
|
return self.merge_candidate is not None and not self.conflicts
|
||||||
|
|
||||||
|
def as_dict(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
include_values: bool = False,
|
||||||
|
include_merge_candidate: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"code": "revision_conflict",
|
||||||
|
"resource": {
|
||||||
|
"type": self.resource_type,
|
||||||
|
"id": self.resource_id,
|
||||||
|
},
|
||||||
|
"current_revision": self.current_revision,
|
||||||
|
"submitted_base_revision": self.submitted_base_revision,
|
||||||
|
"retryable": True,
|
||||||
|
"safe_merge_available": self.safe_merge_available,
|
||||||
|
"conflicts": [
|
||||||
|
conflict.as_dict(include_values=include_values)
|
||||||
|
for conflict in self.conflicts[:100]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if self.refresh_path:
|
||||||
|
result["refresh_path"] = self.refresh_path
|
||||||
|
if self.current_etag:
|
||||||
|
result["current_etag"] = self.current_etag
|
||||||
|
if include_merge_candidate and self.safe_merge_available:
|
||||||
|
result["merge_candidate"] = copy.deepcopy(self.merge_candidate)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class MergeConflict:
|
||||||
|
path: str
|
||||||
|
kind: str
|
||||||
|
base_value: Any = None
|
||||||
|
local_value: Any = None
|
||||||
|
current_value: Any = None
|
||||||
|
|
||||||
|
def as_dict(self, *, include_values: bool = False) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"path": self.path or "/",
|
||||||
|
"kind": self.kind,
|
||||||
|
}
|
||||||
|
if include_values:
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
"base_value": _bounded_json_value(self.base_value),
|
||||||
|
"local_value": _bounded_json_value(self.local_value),
|
||||||
|
"current_value": _bounded_json_value(self.current_value),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ThreeWayMergeResult:
|
||||||
|
value: Any
|
||||||
|
conflicts: list[MergeConflict] = field(default_factory=list)
|
||||||
|
applied_paths: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def merged(self) -> bool:
|
||||||
|
return not self.conflicts
|
||||||
|
|
||||||
|
|
||||||
|
ProtectedPath = str | Callable[[str], bool]
|
||||||
|
|
||||||
|
|
||||||
|
def strong_resource_etag(
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
revision: int,
|
||||||
|
) -> str:
|
||||||
|
"""Return an opaque strong ETag for one mutable aggregate revision."""
|
||||||
|
|
||||||
|
normalized_revision = int(revision)
|
||||||
|
if normalized_revision < 1:
|
||||||
|
raise ValueError("Resource revisions must be positive integers")
|
||||||
|
digest = hashlib.sha256(
|
||||||
|
"\x00".join(
|
||||||
|
(
|
||||||
|
"govoplan-strong-revision-v1",
|
||||||
|
str(resource_type),
|
||||||
|
str(resource_id),
|
||||||
|
str(normalized_revision),
|
||||||
|
)
|
||||||
|
).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
return f'"sha256-{digest}"'
|
||||||
|
|
||||||
|
|
||||||
|
def if_match_matches(header_value: str | None, expected_etag: str) -> bool:
|
||||||
|
"""Apply strong comparison semantics to an If-Match header."""
|
||||||
|
|
||||||
|
if not header_value:
|
||||||
|
return False
|
||||||
|
for raw_candidate in header_value.split(","):
|
||||||
|
candidate = raw_candidate.strip()
|
||||||
|
if candidate == "*":
|
||||||
|
return True
|
||||||
|
if candidate.startswith("W/"):
|
||||||
|
continue
|
||||||
|
if candidate == expected_etag:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def assert_revision_precondition(
|
||||||
|
if_match: str | None,
|
||||||
|
*,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
submitted_base_revision: int,
|
||||||
|
) -> None:
|
||||||
|
if not if_match:
|
||||||
|
raise MissingPreconditionError(
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
)
|
||||||
|
submitted_etag = strong_resource_etag(
|
||||||
|
resource_type,
|
||||||
|
resource_id,
|
||||||
|
submitted_base_revision,
|
||||||
|
)
|
||||||
|
if not if_match_matches(if_match, submitted_etag):
|
||||||
|
raise ConcurrencyError(
|
||||||
|
"If-Match does not identify the submitted base revision."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def claim_revision(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
model: type[Any],
|
||||||
|
filters: Iterable[Any],
|
||||||
|
revision_attribute: str,
|
||||||
|
expected_revision: int,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
refresh_path: str | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Atomically claim the next revision in the caller's transaction."""
|
||||||
|
|
||||||
|
revision_column = getattr(model, revision_attribute)
|
||||||
|
expected = int(expected_revision)
|
||||||
|
normalized_filters = tuple(filters)
|
||||||
|
query = session.query(model).filter(*normalized_filters)
|
||||||
|
updated = query.filter(revision_column == expected).update(
|
||||||
|
{revision_column: revision_column + 1},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
if updated == 1:
|
||||||
|
session.flush()
|
||||||
|
return expected + 1
|
||||||
|
|
||||||
|
current = (
|
||||||
|
session.query(revision_column)
|
||||||
|
.filter(*normalized_filters)
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
if current is None:
|
||||||
|
raise LookupError(f"{resource_type} {resource_id} was not found")
|
||||||
|
raise RevisionConflictError(
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
current_revision=int(current),
|
||||||
|
submitted_base_revision=expected,
|
||||||
|
refresh_path=refresh_path,
|
||||||
|
current_etag=strong_resource_etag(
|
||||||
|
resource_type,
|
||||||
|
resource_id,
|
||||||
|
int(current),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def three_way_merge(
|
||||||
|
base: Any,
|
||||||
|
local: Any,
|
||||||
|
current: Any,
|
||||||
|
*,
|
||||||
|
protected_paths: Sequence[ProtectedPath] = (),
|
||||||
|
stable_id_fields: Sequence[str] = ("id",),
|
||||||
|
) -> ThreeWayMergeResult:
|
||||||
|
"""Conservatively merge local changes onto a concurrently changed value."""
|
||||||
|
|
||||||
|
return _merge_value(
|
||||||
|
copy.deepcopy(base),
|
||||||
|
copy.deepcopy(local),
|
||||||
|
copy.deepcopy(current),
|
||||||
|
path="",
|
||||||
|
protected_paths=tuple(protected_paths),
|
||||||
|
stable_id_fields=tuple(stable_id_fields),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_value(
|
||||||
|
base: Any,
|
||||||
|
local: Any,
|
||||||
|
current: Any,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
protected_paths: Sequence[ProtectedPath],
|
||||||
|
stable_id_fields: Sequence[str],
|
||||||
|
) -> ThreeWayMergeResult:
|
||||||
|
local_changed = local != base
|
||||||
|
current_changed = current != base
|
||||||
|
if not local_changed:
|
||||||
|
return ThreeWayMergeResult(value=current)
|
||||||
|
if local == current:
|
||||||
|
return ThreeWayMergeResult(value=current)
|
||||||
|
if _is_protected(path, protected_paths):
|
||||||
|
return _conflict(path, "protected_path", base, local, current)
|
||||||
|
if not current_changed:
|
||||||
|
return ThreeWayMergeResult(
|
||||||
|
value=local,
|
||||||
|
applied_paths=[path or "/"],
|
||||||
|
)
|
||||||
|
if isinstance(base, Mapping) and isinstance(local, Mapping) and isinstance(current, Mapping):
|
||||||
|
return _merge_mapping(
|
||||||
|
base,
|
||||||
|
local,
|
||||||
|
current,
|
||||||
|
path=path,
|
||||||
|
protected_paths=protected_paths,
|
||||||
|
stable_id_fields=stable_id_fields,
|
||||||
|
)
|
||||||
|
if isinstance(base, list) and isinstance(local, list) and isinstance(current, list):
|
||||||
|
return _merge_list(
|
||||||
|
base,
|
||||||
|
local,
|
||||||
|
current,
|
||||||
|
path=path,
|
||||||
|
protected_paths=protected_paths,
|
||||||
|
stable_id_fields=stable_id_fields,
|
||||||
|
)
|
||||||
|
return _conflict(path, "same_path_changed", base, local, current)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_mapping(
|
||||||
|
base: Mapping[str, Any],
|
||||||
|
local: Mapping[str, Any],
|
||||||
|
current: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
protected_paths: Sequence[ProtectedPath],
|
||||||
|
stable_id_fields: Sequence[str],
|
||||||
|
) -> ThreeWayMergeResult:
|
||||||
|
missing = object()
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
conflicts: list[MergeConflict] = []
|
||||||
|
applied_paths: list[str] = []
|
||||||
|
keys = list(dict.fromkeys((*current.keys(), *local.keys(), *base.keys())))
|
||||||
|
for key in keys:
|
||||||
|
child_path = _join_path(path, str(key))
|
||||||
|
base_value = base.get(key, missing)
|
||||||
|
local_value = local.get(key, missing)
|
||||||
|
current_value = current.get(key, missing)
|
||||||
|
merged = _merge_presence(
|
||||||
|
base_value,
|
||||||
|
local_value,
|
||||||
|
current_value,
|
||||||
|
missing=missing,
|
||||||
|
path=child_path,
|
||||||
|
protected_paths=protected_paths,
|
||||||
|
stable_id_fields=stable_id_fields,
|
||||||
|
)
|
||||||
|
conflicts.extend(merged.conflicts)
|
||||||
|
applied_paths.extend(merged.applied_paths)
|
||||||
|
if merged.value is not missing:
|
||||||
|
result[str(key)] = merged.value
|
||||||
|
return ThreeWayMergeResult(
|
||||||
|
value=result,
|
||||||
|
conflicts=conflicts,
|
||||||
|
applied_paths=applied_paths,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_presence(
|
||||||
|
base: Any,
|
||||||
|
local: Any,
|
||||||
|
current: Any,
|
||||||
|
*,
|
||||||
|
missing: object,
|
||||||
|
path: str,
|
||||||
|
protected_paths: Sequence[ProtectedPath],
|
||||||
|
stable_id_fields: Sequence[str],
|
||||||
|
) -> ThreeWayMergeResult:
|
||||||
|
if local is missing and current is missing:
|
||||||
|
return ThreeWayMergeResult(value=missing)
|
||||||
|
if base is missing:
|
||||||
|
if local is missing:
|
||||||
|
return ThreeWayMergeResult(value=current)
|
||||||
|
if current is missing:
|
||||||
|
if _is_protected(path, protected_paths):
|
||||||
|
return _conflict(path, "protected_path", None, local, None)
|
||||||
|
return ThreeWayMergeResult(value=local, applied_paths=[path])
|
||||||
|
if local == current:
|
||||||
|
return ThreeWayMergeResult(value=current)
|
||||||
|
return _conflict(path, "concurrent_add", None, local, current)
|
||||||
|
if local is missing:
|
||||||
|
if current == base:
|
||||||
|
if _is_protected(path, protected_paths):
|
||||||
|
return _conflict(path, "protected_path", base, None, current)
|
||||||
|
return ThreeWayMergeResult(value=missing, applied_paths=[path])
|
||||||
|
return _conflict(path, "delete_vs_edit", base, None, current)
|
||||||
|
if current is missing:
|
||||||
|
if local == base:
|
||||||
|
return ThreeWayMergeResult(value=missing)
|
||||||
|
return _conflict(path, "edit_vs_delete", base, local, None)
|
||||||
|
return _merge_value(
|
||||||
|
base,
|
||||||
|
local,
|
||||||
|
current,
|
||||||
|
path=path,
|
||||||
|
protected_paths=protected_paths,
|
||||||
|
stable_id_fields=stable_id_fields,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_list(
|
||||||
|
base: list[Any],
|
||||||
|
local: list[Any],
|
||||||
|
current: list[Any],
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
protected_paths: Sequence[ProtectedPath],
|
||||||
|
stable_id_fields: Sequence[str],
|
||||||
|
) -> ThreeWayMergeResult:
|
||||||
|
identity_field = _stable_identity_field(
|
||||||
|
(base, local, current),
|
||||||
|
stable_id_fields,
|
||||||
|
)
|
||||||
|
if identity_field is None:
|
||||||
|
return _conflict(path, "unkeyed_collection", base, local, current)
|
||||||
|
|
||||||
|
base_by_id = {str(item[identity_field]): item for item in base}
|
||||||
|
local_by_id = {str(item[identity_field]): item for item in local}
|
||||||
|
current_by_id = {str(item[identity_field]): item for item in current}
|
||||||
|
base_order = list(base_by_id)
|
||||||
|
local_order = list(local_by_id)
|
||||||
|
current_order = list(current_by_id)
|
||||||
|
|
||||||
|
local_reordered = _common_order(local_order, base_order) != _common_order(
|
||||||
|
base_order,
|
||||||
|
local_order,
|
||||||
|
)
|
||||||
|
current_reordered = _common_order(current_order, base_order) != _common_order(
|
||||||
|
base_order,
|
||||||
|
current_order,
|
||||||
|
)
|
||||||
|
if local_reordered and current_reordered and local_order != current_order:
|
||||||
|
return _conflict(path, "collection_reorder", base_order, local_order, current_order)
|
||||||
|
|
||||||
|
result_by_id: dict[str, Any] = {}
|
||||||
|
conflicts: list[MergeConflict] = []
|
||||||
|
applied_paths: list[str] = []
|
||||||
|
identities = list(dict.fromkeys((*current_order, *local_order, *base_order)))
|
||||||
|
missing = object()
|
||||||
|
for identity in identities:
|
||||||
|
item_path = _join_path(path, f"{identity_field}={identity}")
|
||||||
|
merged = _merge_presence(
|
||||||
|
base_by_id.get(identity, missing),
|
||||||
|
local_by_id.get(identity, missing),
|
||||||
|
current_by_id.get(identity, missing),
|
||||||
|
missing=missing,
|
||||||
|
path=item_path,
|
||||||
|
protected_paths=protected_paths,
|
||||||
|
stable_id_fields=stable_id_fields,
|
||||||
|
)
|
||||||
|
conflicts.extend(merged.conflicts)
|
||||||
|
applied_paths.extend(merged.applied_paths)
|
||||||
|
if merged.value is not missing:
|
||||||
|
result_by_id[identity] = merged.value
|
||||||
|
|
||||||
|
order_source = local_order if local_reordered and not current_reordered else current_order
|
||||||
|
merged_order = [identity for identity in order_source if identity in result_by_id]
|
||||||
|
for identity in identities:
|
||||||
|
if identity in result_by_id and identity not in merged_order:
|
||||||
|
merged_order.append(identity)
|
||||||
|
return ThreeWayMergeResult(
|
||||||
|
value=[result_by_id[identity] for identity in merged_order],
|
||||||
|
conflicts=conflicts,
|
||||||
|
applied_paths=applied_paths,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stable_identity_field(
|
||||||
|
values: Sequence[list[Any]],
|
||||||
|
candidates: Sequence[str],
|
||||||
|
) -> str | None:
|
||||||
|
all_items = [item for value in values for item in value]
|
||||||
|
if not all_items or not all(isinstance(item, Mapping) for item in all_items):
|
||||||
|
return None
|
||||||
|
for candidate in candidates:
|
||||||
|
valid = True
|
||||||
|
for value in values:
|
||||||
|
identities = [
|
||||||
|
str(item.get(candidate, "")).strip()
|
||||||
|
for item in value
|
||||||
|
if isinstance(item, Mapping)
|
||||||
|
]
|
||||||
|
if any(not identity for identity in identities) or len(identities) != len(
|
||||||
|
set(identities)
|
||||||
|
):
|
||||||
|
valid = False
|
||||||
|
break
|
||||||
|
if valid:
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _common_order(left: Sequence[str], right: Sequence[str]) -> list[str]:
|
||||||
|
right_set = set(right)
|
||||||
|
return [item for item in left if item in right_set]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_protected(path: str, protected_paths: Sequence[ProtectedPath]) -> bool:
|
||||||
|
normalized = path or "/"
|
||||||
|
for protected in protected_paths:
|
||||||
|
if callable(protected):
|
||||||
|
if protected(normalized):
|
||||||
|
return True
|
||||||
|
continue
|
||||||
|
prefix = protected.rstrip("/") or "/"
|
||||||
|
if normalized == prefix or normalized.startswith(f"{prefix}/"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _join_path(parent: str, segment: str) -> str:
|
||||||
|
escaped = segment.replace("~", "~0").replace("/", "~1")
|
||||||
|
return f"{parent}/{escaped}" if parent else f"/{escaped}"
|
||||||
|
|
||||||
|
|
||||||
|
def _conflict(
|
||||||
|
path: str,
|
||||||
|
kind: str,
|
||||||
|
base: Any,
|
||||||
|
local: Any,
|
||||||
|
current: Any,
|
||||||
|
) -> ThreeWayMergeResult:
|
||||||
|
return ThreeWayMergeResult(
|
||||||
|
value=current,
|
||||||
|
conflicts=[
|
||||||
|
MergeConflict(
|
||||||
|
path=path or "/",
|
||||||
|
kind=kind,
|
||||||
|
base_value=base,
|
||||||
|
local_value=local,
|
||||||
|
current_value=current,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_json_value(value: Any, *, depth: int = 0) -> Any:
|
||||||
|
if depth >= 4:
|
||||||
|
return {"summary": type(value).__name__}
|
||||||
|
if value is None or isinstance(value, (bool, int, float)):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value[:500]
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
result = {
|
||||||
|
str(key)[:100]: _bounded_json_value(item, depth=depth + 1)
|
||||||
|
for key, item in list(value.items())[:20]
|
||||||
|
}
|
||||||
|
if len(value) > 20:
|
||||||
|
result["_truncated_items"] = len(value) - 20
|
||||||
|
return result
|
||||||
|
if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray)):
|
||||||
|
result = [
|
||||||
|
_bounded_json_value(item, depth=depth + 1)
|
||||||
|
for item in list(value)[:20]
|
||||||
|
]
|
||||||
|
if len(value) > 20:
|
||||||
|
result.append({"_truncated_items": len(value) - 20})
|
||||||
|
return result
|
||||||
|
try:
|
||||||
|
return json.loads(json.dumps(value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {"summary": type(value).__name__}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ConcurrencyError",
|
||||||
|
"MergeConflict",
|
||||||
|
"MissingPreconditionError",
|
||||||
|
"RevisionConflictError",
|
||||||
|
"ThreeWayMergeResult",
|
||||||
|
"assert_revision_precondition",
|
||||||
|
"claim_revision",
|
||||||
|
"if_match_matches",
|
||||||
|
"strong_resource_etag",
|
||||||
|
"three_way_merge",
|
||||||
|
]
|
||||||
@@ -3,11 +3,11 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import Any, Literal, Protocol, runtime_checkable
|
import re
|
||||||
|
from typing import Any, Literal, Protocol, cast, runtime_checkable
|
||||||
|
|
||||||
from govoplan_core.core.module_package_catalog import (
|
from govoplan_core.core.module_package_catalog import (
|
||||||
_canonical_catalog_bytes,
|
_canonical_catalog_bytes,
|
||||||
@@ -20,6 +20,16 @@ from govoplan_core.core.module_package_catalog import (
|
|||||||
_is_http_url,
|
_is_http_url,
|
||||||
_load_private_key,
|
_load_private_key,
|
||||||
_parse_trusted_keys,
|
_parse_trusted_keys,
|
||||||
|
_record_catalog_acceptance,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.external_references import (
|
||||||
|
IntegrationMaturity,
|
||||||
|
SOURCE_AUTHORITY_MODES,
|
||||||
|
SourceAuthorityMode,
|
||||||
|
integration_maturity_rank,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.infrastructure_capabilities import (
|
||||||
|
InfrastructureCapabilityReceipt,
|
||||||
)
|
)
|
||||||
from govoplan_core.security.http_fetch import fetch_http_text
|
from govoplan_core.security.http_fetch import fetch_http_text
|
||||||
|
|
||||||
@@ -28,6 +38,166 @@ CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider"
|
|||||||
|
|
||||||
DiagnosticSeverity = Literal["blocker", "warning", "info"]
|
DiagnosticSeverity = Literal["blocker", "warning", "info"]
|
||||||
PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"]
|
PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"]
|
||||||
|
ConfigurationPackageClass = Literal[
|
||||||
|
"reference",
|
||||||
|
"product",
|
||||||
|
"sector",
|
||||||
|
"deployment",
|
||||||
|
"integration",
|
||||||
|
]
|
||||||
|
ConfigurationPackageEvidenceKind = Literal[
|
||||||
|
"target_test",
|
||||||
|
"migration",
|
||||||
|
"upgrade",
|
||||||
|
"recovery",
|
||||||
|
"security",
|
||||||
|
"operations",
|
||||||
|
"accessibility",
|
||||||
|
"privacy",
|
||||||
|
"documentation",
|
||||||
|
]
|
||||||
|
|
||||||
|
CONFIGURATION_PACKAGE_CLASSES: tuple[ConfigurationPackageClass, ...] = (
|
||||||
|
"reference",
|
||||||
|
"product",
|
||||||
|
"sector",
|
||||||
|
"deployment",
|
||||||
|
"integration",
|
||||||
|
)
|
||||||
|
CONFIGURATION_PACKAGE_EVIDENCE_KINDS: tuple[
|
||||||
|
ConfigurationPackageEvidenceKind, ...
|
||||||
|
] = (
|
||||||
|
"target_test",
|
||||||
|
"migration",
|
||||||
|
"upgrade",
|
||||||
|
"recovery",
|
||||||
|
"security",
|
||||||
|
"operations",
|
||||||
|
"accessibility",
|
||||||
|
"privacy",
|
||||||
|
"documentation",
|
||||||
|
)
|
||||||
|
_SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConfigurationPackageParent:
|
||||||
|
package_id: str
|
||||||
|
version: str
|
||||||
|
relation: Literal["derived_from", "specializes", "extends"] = "derived_from"
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.package_id.strip() or not self.version.strip():
|
||||||
|
raise ValueError("Configuration package parent id and version are required.")
|
||||||
|
if self.relation not in {"derived_from", "specializes", "extends"}:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported configuration package parent relation: {self.relation!r}."
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationPackageParent":
|
||||||
|
relation = _optional_str(value, "relation") or "derived_from"
|
||||||
|
if relation not in {"derived_from", "specializes", "extends"}:
|
||||||
|
raise ValueError(f"Unsupported configuration package parent relation: {relation!r}.")
|
||||||
|
return cls(
|
||||||
|
package_id=_required_str(value, "package_id"),
|
||||||
|
version=_required_str(value, "version"),
|
||||||
|
relation=relation,
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"package_id": self.package_id,
|
||||||
|
"version": self.version,
|
||||||
|
"relation": self.relation,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConfigurationPackageEvidence:
|
||||||
|
kind: ConfigurationPackageEvidenceKind
|
||||||
|
reference: str
|
||||||
|
summary: str
|
||||||
|
checksum: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.kind not in CONFIGURATION_PACKAGE_EVIDENCE_KINDS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported configuration package evidence kind: {self.kind!r}."
|
||||||
|
)
|
||||||
|
if not self.reference.strip() or not self.summary.strip():
|
||||||
|
raise ValueError(
|
||||||
|
"Configuration package evidence reference and summary are required."
|
||||||
|
)
|
||||||
|
if self.checksum is not None and not _SHA256_RE.fullmatch(self.checksum):
|
||||||
|
raise ValueError(
|
||||||
|
"Configuration package evidence checksum must use sha256:<64 lowercase hex>."
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationPackageEvidence":
|
||||||
|
kind = _required_str(value, "kind")
|
||||||
|
if kind not in CONFIGURATION_PACKAGE_EVIDENCE_KINDS:
|
||||||
|
raise ValueError(f"Unsupported configuration package evidence kind: {kind!r}.")
|
||||||
|
return cls(
|
||||||
|
kind=kind,
|
||||||
|
reference=_required_str(value, "reference"),
|
||||||
|
summary=_required_str(value, "summary"),
|
||||||
|
checksum=_optional_str(value, "checksum"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"kind": self.kind,
|
||||||
|
"reference": self.reference,
|
||||||
|
"summary": self.summary,
|
||||||
|
"checksum": self.checksum,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConfigurationProviderExpectation:
|
||||||
|
provider_id: str
|
||||||
|
authority_mode: SourceAuthorityMode
|
||||||
|
minimum_maturity: IntegrationMaturity
|
||||||
|
binding_ref: str | None = None
|
||||||
|
health_expectation: str = "healthy"
|
||||||
|
freshness_expectation: str | None = None
|
||||||
|
recovery_expectation: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.provider_id.strip():
|
||||||
|
raise ValueError("Configuration provider expectation id is required.")
|
||||||
|
if self.authority_mode not in SOURCE_AUTHORITY_MODES:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported provider authority mode: {self.authority_mode!r}."
|
||||||
|
)
|
||||||
|
integration_maturity_rank(self.minimum_maturity)
|
||||||
|
if not self.health_expectation.strip():
|
||||||
|
raise ValueError("Provider health expectation is required.")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationProviderExpectation":
|
||||||
|
return cls(
|
||||||
|
provider_id=_required_str(value, "provider_id"),
|
||||||
|
authority_mode=_required_str(value, "authority_mode"),
|
||||||
|
minimum_maturity=_required_str(value, "minimum_maturity"),
|
||||||
|
binding_ref=_optional_str(value, "binding_ref"),
|
||||||
|
health_expectation=_optional_str(value, "health_expectation") or "healthy",
|
||||||
|
freshness_expectation=_optional_str(value, "freshness_expectation"),
|
||||||
|
recovery_expectation=_optional_str(value, "recovery_expectation"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"provider_id": self.provider_id,
|
||||||
|
"authority_mode": self.authority_mode,
|
||||||
|
"minimum_maturity": self.minimum_maturity,
|
||||||
|
"binding_ref": self.binding_ref,
|
||||||
|
"health_expectation": self.health_expectation,
|
||||||
|
"freshness_expectation": self.freshness_expectation,
|
||||||
|
"recovery_expectation": self.recovery_expectation,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -75,6 +245,7 @@ class ConfigurationPackageManifest:
|
|||||||
package_id: str
|
package_id: str
|
||||||
name: str
|
name: str
|
||||||
version: str
|
version: str
|
||||||
|
package_class: ConfigurationPackageClass = "product"
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
publisher: str | None = None
|
publisher: str | None = None
|
||||||
category: str | None = None
|
category: str | None = None
|
||||||
@@ -88,6 +259,29 @@ class ConfigurationPackageManifest:
|
|||||||
artifact_ref: str | None = None
|
artifact_ref: str | None = None
|
||||||
artifact_sha256: str | None = None
|
artifact_sha256: str | None = None
|
||||||
signature: Mapping[str, Any] | None = None
|
signature: Mapping[str, Any] | None = None
|
||||||
|
parents: tuple[ConfigurationPackageParent, ...] = ()
|
||||||
|
evidence: tuple[ConfigurationPackageEvidence, ...] = ()
|
||||||
|
provider_expectations: tuple[ConfigurationProviderExpectation, ...] = ()
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.package_class not in CONFIGURATION_PACKAGE_CLASSES:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported configuration package class: {self.package_class!r}."
|
||||||
|
)
|
||||||
|
parent_keys = {(item.package_id, item.version) for item in self.parents}
|
||||||
|
if len(parent_keys) != len(self.parents):
|
||||||
|
raise ValueError("Configuration package parents must be unique.")
|
||||||
|
evidence_keys = {(item.kind, item.reference) for item in self.evidence}
|
||||||
|
if len(evidence_keys) != len(self.evidence):
|
||||||
|
raise ValueError("Configuration package evidence must be unique.")
|
||||||
|
provider_ids = [item.provider_id for item in self.provider_expectations]
|
||||||
|
if len(provider_ids) != len(set(provider_ids)):
|
||||||
|
raise ValueError("Configuration package provider expectations must be unique.")
|
||||||
|
for expectation in self.provider_expectations:
|
||||||
|
integration_maturity_rank(expectation.minimum_maturity)
|
||||||
|
issues = configuration_package_claim_issues(self)
|
||||||
|
if issues:
|
||||||
|
raise ValueError("Invalid configuration package claim: " + "; ".join(issues))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationPackageManifest":
|
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationPackageManifest":
|
||||||
@@ -95,6 +289,7 @@ class ConfigurationPackageManifest:
|
|||||||
package_id=_required_str(value, "package_id"),
|
package_id=_required_str(value, "package_id"),
|
||||||
name=_required_str(value, "name"),
|
name=_required_str(value, "name"),
|
||||||
version=_required_str(value, "version"),
|
version=_required_str(value, "version"),
|
||||||
|
package_class=_optional_str(value, "package_class") or "product",
|
||||||
description=_optional_str(value, "description"),
|
description=_optional_str(value, "description"),
|
||||||
publisher=_optional_str(value, "publisher"),
|
publisher=_optional_str(value, "publisher"),
|
||||||
category=_optional_str(value, "category"),
|
category=_optional_str(value, "category"),
|
||||||
@@ -108,6 +303,21 @@ class ConfigurationPackageManifest:
|
|||||||
artifact_ref=_optional_str(value, "artifact_ref"),
|
artifact_ref=_optional_str(value, "artifact_ref"),
|
||||||
artifact_sha256=_optional_str(value, "artifact_sha256"),
|
artifact_sha256=_optional_str(value, "artifact_sha256"),
|
||||||
signature=value.get("signature") if isinstance(value.get("signature"), Mapping) else None,
|
signature=value.get("signature") if isinstance(value.get("signature"), Mapping) else None,
|
||||||
|
parents=tuple(
|
||||||
|
ConfigurationPackageParent.from_mapping(item)
|
||||||
|
for item in _object_list(value.get("parents"), field_name="parents")
|
||||||
|
),
|
||||||
|
evidence=tuple(
|
||||||
|
ConfigurationPackageEvidence.from_mapping(item)
|
||||||
|
for item in _object_list(value.get("evidence"), field_name="evidence")
|
||||||
|
),
|
||||||
|
provider_expectations=tuple(
|
||||||
|
ConfigurationProviderExpectation.from_mapping(item)
|
||||||
|
for item in _object_list(
|
||||||
|
value.get("provider_expectations"),
|
||||||
|
field_name="provider_expectations",
|
||||||
|
)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, object]:
|
def to_dict(self) -> dict[str, object]:
|
||||||
@@ -115,12 +325,18 @@ class ConfigurationPackageManifest:
|
|||||||
"package_id": self.package_id,
|
"package_id": self.package_id,
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"version": self.version,
|
"version": self.version,
|
||||||
|
"package_class": self.package_class,
|
||||||
"required_modules": [item.to_dict() for item in self.required_modules],
|
"required_modules": [item.to_dict() for item in self.required_modules],
|
||||||
"required_capabilities": list(self.required_capabilities),
|
"required_capabilities": list(self.required_capabilities),
|
||||||
"optional_modules": [item.to_dict() for item in self.optional_modules],
|
"optional_modules": [item.to_dict() for item in self.optional_modules],
|
||||||
"fragments": [item.to_dict() for item in self.fragments],
|
"fragments": [item.to_dict() for item in self.fragments],
|
||||||
"data_requirements": [dict(item) for item in self.data_requirements],
|
"data_requirements": [dict(item) for item in self.data_requirements],
|
||||||
"tags": list(self.tags),
|
"tags": list(self.tags),
|
||||||
|
"parents": [item.to_dict() for item in self.parents],
|
||||||
|
"evidence": [item.to_dict() for item in self.evidence],
|
||||||
|
"provider_expectations": [
|
||||||
|
item.to_dict() for item in self.provider_expectations
|
||||||
|
],
|
||||||
}
|
}
|
||||||
for key, value in (
|
for key, value in (
|
||||||
("description", self.description),
|
("description", self.description),
|
||||||
@@ -221,7 +437,16 @@ class ConfigurationPreflightContext:
|
|||||||
supplied_data: Mapping[str, Any] = field(default_factory=dict)
|
supplied_data: Mapping[str, Any] = field(default_factory=dict)
|
||||||
installed_modules: Mapping[str, str] = field(default_factory=dict)
|
installed_modules: Mapping[str, str] = field(default_factory=dict)
|
||||||
capabilities: frozenset[str] = frozenset()
|
capabilities: frozenset[str] = frozenset()
|
||||||
|
external_provider_declarations: Mapping[str, Mapping[str, Any]] = field(
|
||||||
|
default_factory=dict
|
||||||
|
)
|
||||||
|
external_provider_states: Mapping[str, Mapping[str, Any]] = field(
|
||||||
|
default_factory=dict
|
||||||
|
)
|
||||||
dry_run: bool = True
|
dry_run: bool = True
|
||||||
|
operator_scopes: frozenset[str] = frozenset()
|
||||||
|
infrastructure_receipt: InfrastructureCapabilityReceipt | None = None
|
||||||
|
infrastructure_receipt_error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -286,6 +511,7 @@ def dry_run_configuration_package(
|
|||||||
|
|
||||||
diagnostics.extend(_module_requirement_diagnostics(manifest, context))
|
diagnostics.extend(_module_requirement_diagnostics(manifest, context))
|
||||||
diagnostics.extend(_capability_requirement_diagnostics(manifest, context))
|
diagnostics.extend(_capability_requirement_diagnostics(manifest, context))
|
||||||
|
diagnostics.extend(_provider_expectation_diagnostics(manifest, context))
|
||||||
for item in manifest.data_requirements:
|
for item in manifest.data_requirements:
|
||||||
requirement = ConfigurationRequiredData.from_mapping(item)
|
requirement = ConfigurationRequiredData.from_mapping(item)
|
||||||
required_data.append(requirement)
|
required_data.append(requirement)
|
||||||
@@ -366,9 +592,14 @@ def apply_configuration_package(
|
|||||||
apply_context = ConfigurationPreflightContext(
|
apply_context = ConfigurationPreflightContext(
|
||||||
tenant_id=context.tenant_id,
|
tenant_id=context.tenant_id,
|
||||||
operator_user_id=context.operator_user_id,
|
operator_user_id=context.operator_user_id,
|
||||||
|
operator_scopes=context.operator_scopes,
|
||||||
supplied_data=supplied_data if supplied_data is not None else context.supplied_data,
|
supplied_data=supplied_data if supplied_data is not None else context.supplied_data,
|
||||||
installed_modules=context.installed_modules,
|
installed_modules=context.installed_modules,
|
||||||
capabilities=context.capabilities,
|
capabilities=context.capabilities,
|
||||||
|
external_provider_declarations=context.external_provider_declarations,
|
||||||
|
external_provider_states=context.external_provider_states,
|
||||||
|
infrastructure_receipt=context.infrastructure_receipt,
|
||||||
|
infrastructure_receipt_error=context.infrastructure_receipt_error,
|
||||||
dry_run=False,
|
dry_run=False,
|
||||||
)
|
)
|
||||||
preflight = dry_run_configuration_package(manifest, providers, apply_context)
|
preflight = dry_run_configuration_package(manifest, providers, apply_context)
|
||||||
@@ -636,33 +867,158 @@ def sign_configuration_package_catalog(*, path: Path, key_id: str, private_key_p
|
|||||||
|
|
||||||
|
|
||||||
def record_configuration_package_catalog_acceptance(validation: dict[str, object]) -> None:
|
def record_configuration_package_catalog_acceptance(validation: dict[str, object]) -> None:
|
||||||
state_path = _configured_sequence_state_path()
|
_record_catalog_acceptance(
|
||||||
if state_path is None or validation.get("valid") is not True:
|
validation,
|
||||||
return
|
state_path=_configured_sequence_state_path(),
|
||||||
channel = validation.get("channel")
|
)
|
||||||
sequence = validation.get("sequence")
|
|
||||||
if not isinstance(channel, str) or not isinstance(sequence, int):
|
|
||||||
return
|
def configuration_package_claim_issues(
|
||||||
try:
|
manifest: ConfigurationPackageManifest,
|
||||||
state = json.loads(state_path.read_text(encoding="utf-8")) if state_path.exists() else {}
|
) -> tuple[str, ...]:
|
||||||
except json.JSONDecodeError:
|
evidence_kinds = {item.kind for item in manifest.evidence}
|
||||||
state = {}
|
required_evidence: dict[str, frozenset[str]] = {
|
||||||
if not isinstance(state, dict):
|
"reference": frozenset(
|
||||||
state = {}
|
{
|
||||||
channels = state.get("channels")
|
"target_test",
|
||||||
if not isinstance(channels, dict):
|
"recovery",
|
||||||
channels = {}
|
"security",
|
||||||
channel_state = channels.get(channel)
|
"operations",
|
||||||
if not isinstance(channel_state, dict):
|
"accessibility",
|
||||||
channel_state = {}
|
"privacy",
|
||||||
channel_state["last_sequence"] = max(int(channel_state.get("last_sequence") or 0), sequence)
|
"documentation",
|
||||||
channel_state["accepted_at"] = datetime.now(tz=UTC).isoformat().replace("+00:00", "Z")
|
}
|
||||||
channel_state["key_id"] = validation.get("key_id")
|
),
|
||||||
channel_state["source"] = validation.get("source") or validation.get("path")
|
"product": frozenset(),
|
||||||
channels[channel] = channel_state
|
"sector": frozenset({"documentation"}),
|
||||||
state["channels"] = channels
|
"deployment": frozenset(
|
||||||
state_path.parent.mkdir(parents=True, exist_ok=True)
|
{"target_test", "recovery", "security", "operations"}
|
||||||
state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
),
|
||||||
|
"integration": frozenset(
|
||||||
|
{"target_test", "recovery", "operations", "documentation"}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
issues: list[str] = []
|
||||||
|
missing = sorted(required_evidence[manifest.package_class] - evidence_kinds)
|
||||||
|
if missing:
|
||||||
|
issues.append(
|
||||||
|
f"{manifest.package_class} package is missing evidence: "
|
||||||
|
+ ", ".join(missing)
|
||||||
|
)
|
||||||
|
if manifest.package_class in {"reference", "deployment", "integration"}:
|
||||||
|
unbound = sorted(
|
||||||
|
item.kind
|
||||||
|
for item in manifest.evidence
|
||||||
|
if item.kind != "documentation" and item.checksum is None
|
||||||
|
)
|
||||||
|
if unbound:
|
||||||
|
issues.append(
|
||||||
|
f"{manifest.package_class} package has evidence without checksums: "
|
||||||
|
+ ", ".join(unbound)
|
||||||
|
)
|
||||||
|
if manifest.package_class == "sector" and not manifest.parents:
|
||||||
|
issues.append("sector packages must declare a parent package/version")
|
||||||
|
if manifest.package_class == "integration" and not manifest.provider_expectations:
|
||||||
|
issues.append("integration packages must declare external provider expectations")
|
||||||
|
return tuple(issues)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_configuration_package_derivation(
|
||||||
|
child: ConfigurationPackageManifest,
|
||||||
|
parent: ConfigurationPackageManifest,
|
||||||
|
) -> tuple[ConfigurationDiagnostic, ...]:
|
||||||
|
diagnostics: list[ConfigurationDiagnostic] = []
|
||||||
|
if not any(
|
||||||
|
item.package_id == parent.package_id and item.version == parent.version
|
||||||
|
for item in child.parents
|
||||||
|
):
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="package_parent_provenance_missing",
|
||||||
|
message=(
|
||||||
|
f"Package {child.package_id!r} does not declare parent "
|
||||||
|
f"{parent.package_id}@{parent.version}."
|
||||||
|
),
|
||||||
|
object_ref=parent.package_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
child_modules = {item.module_id: item for item in child.required_modules}
|
||||||
|
for requirement in parent.required_modules:
|
||||||
|
candidate = child_modules.get(requirement.module_id)
|
||||||
|
if candidate is None or (
|
||||||
|
requirement.version is not None
|
||||||
|
and candidate.version != requirement.version
|
||||||
|
):
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="package_parent_module_constraint_loosened",
|
||||||
|
message=(
|
||||||
|
f"Derived package loosens parent module requirement "
|
||||||
|
f"{requirement.module_id!r}."
|
||||||
|
),
|
||||||
|
module_id=requirement.module_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for capability in set(parent.required_capabilities) - set(
|
||||||
|
child.required_capabilities
|
||||||
|
):
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="package_parent_capability_constraint_loosened",
|
||||||
|
message=(
|
||||||
|
f"Derived package removes required capability {capability!r}."
|
||||||
|
),
|
||||||
|
object_ref=capability,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
child_providers = {
|
||||||
|
item.provider_id: item for item in child.provider_expectations
|
||||||
|
}
|
||||||
|
for expectation in parent.provider_expectations:
|
||||||
|
candidate = child_providers.get(expectation.provider_id)
|
||||||
|
if candidate is None:
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="package_parent_provider_constraint_removed",
|
||||||
|
message=(
|
||||||
|
f"Derived package removes provider expectation "
|
||||||
|
f"{expectation.provider_id!r}."
|
||||||
|
),
|
||||||
|
object_ref=expectation.provider_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if candidate.authority_mode != expectation.authority_mode:
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="package_parent_authority_mode_changed",
|
||||||
|
message=(
|
||||||
|
f"Derived package changes authority mode for provider "
|
||||||
|
f"{expectation.provider_id!r}."
|
||||||
|
),
|
||||||
|
object_ref=expectation.provider_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if integration_maturity_rank(
|
||||||
|
candidate.minimum_maturity
|
||||||
|
) < integration_maturity_rank(expectation.minimum_maturity):
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="package_parent_provider_maturity_loosened",
|
||||||
|
message=(
|
||||||
|
f"Derived package lowers provider maturity for "
|
||||||
|
f"{expectation.provider_id!r}."
|
||||||
|
),
|
||||||
|
object_ref=expectation.provider_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(diagnostics)
|
||||||
|
|
||||||
|
|
||||||
def _configuration_package_manifest(package: ConfigurationPackageManifest | Mapping[str, Any]) -> ConfigurationPackageManifest:
|
def _configuration_package_manifest(package: ConfigurationPackageManifest | Mapping[str, Any]) -> ConfigurationPackageManifest:
|
||||||
@@ -716,6 +1072,194 @@ def _capability_requirement_diagnostics(manifest: ConfigurationPackageManifest,
|
|||||||
return diagnostics
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_expectation_diagnostics(
|
||||||
|
manifest: ConfigurationPackageManifest,
|
||||||
|
context: ConfigurationPreflightContext,
|
||||||
|
) -> list[ConfigurationDiagnostic]:
|
||||||
|
diagnostics: list[ConfigurationDiagnostic] = []
|
||||||
|
for expectation in manifest.provider_expectations:
|
||||||
|
declaration = context.external_provider_declarations.get(
|
||||||
|
expectation.provider_id
|
||||||
|
)
|
||||||
|
if declaration is None:
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="external_provider_missing",
|
||||||
|
message=(
|
||||||
|
f"Required external provider {expectation.provider_id!r} "
|
||||||
|
"is not installed or declared."
|
||||||
|
),
|
||||||
|
object_ref=expectation.provider_id,
|
||||||
|
resolution=(
|
||||||
|
"Install and enable a module exposing the declared provider."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
supported_modes = {
|
||||||
|
str(item)
|
||||||
|
for item in declaration.get("authority_modes", ())
|
||||||
|
if str(item).strip()
|
||||||
|
}
|
||||||
|
if expectation.authority_mode not in supported_modes:
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="external_provider_authority_mode_unsupported",
|
||||||
|
message=(
|
||||||
|
f"Provider {expectation.provider_id!r} does not support "
|
||||||
|
f"authority mode {expectation.authority_mode!r}."
|
||||||
|
),
|
||||||
|
object_ref=expectation.provider_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
actual_maturity = str(declaration.get("maturity") or "discover")
|
||||||
|
try:
|
||||||
|
maturity_sufficient = integration_maturity_rank(
|
||||||
|
cast(IntegrationMaturity, actual_maturity)
|
||||||
|
) >= integration_maturity_rank(expectation.minimum_maturity)
|
||||||
|
except ValueError:
|
||||||
|
maturity_sufficient = False
|
||||||
|
if not maturity_sufficient:
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="external_provider_maturity_insufficient",
|
||||||
|
message=(
|
||||||
|
f"Provider {expectation.provider_id!r} has maturity "
|
||||||
|
f"{actual_maturity!r}; {expectation.minimum_maturity!r} is required."
|
||||||
|
),
|
||||||
|
object_ref=expectation.provider_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
provider_state = context.external_provider_states.get(
|
||||||
|
expectation.provider_id
|
||||||
|
)
|
||||||
|
if provider_state is None:
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="warning",
|
||||||
|
code="external_provider_health_unverified",
|
||||||
|
message=(
|
||||||
|
f"Provider {expectation.provider_id!r} has no current "
|
||||||
|
"health/freshness observation."
|
||||||
|
),
|
||||||
|
object_ref=expectation.provider_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
state = provider_state
|
||||||
|
if expectation.binding_ref is not None:
|
||||||
|
bindings = provider_state.get("bindings")
|
||||||
|
matching_binding = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in bindings
|
||||||
|
if isinstance(item, Mapping)
|
||||||
|
and str(item.get("binding_ref") or "")
|
||||||
|
== expectation.binding_ref
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
) if isinstance(bindings, Sequence) and not isinstance(
|
||||||
|
bindings, (str, bytes)
|
||||||
|
) else None
|
||||||
|
if matching_binding is None and str(
|
||||||
|
provider_state.get("binding_ref") or ""
|
||||||
|
) == expectation.binding_ref:
|
||||||
|
matching_binding = provider_state
|
||||||
|
if matching_binding is None:
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="external_provider_binding_mismatch",
|
||||||
|
message=(
|
||||||
|
f"Provider {expectation.provider_id!r} is not observed through "
|
||||||
|
f"required binding {expectation.binding_ref!r}."
|
||||||
|
),
|
||||||
|
object_ref=expectation.provider_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
state = matching_binding
|
||||||
|
health = str(state.get("health") or state.get("health_state") or "unknown")
|
||||||
|
accepted_health = (
|
||||||
|
{"ok", "healthy"}
|
||||||
|
if expectation.health_expectation == "healthy"
|
||||||
|
else {expectation.health_expectation}
|
||||||
|
)
|
||||||
|
if health not in accepted_health:
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="external_provider_unhealthy",
|
||||||
|
message=(
|
||||||
|
f"Provider {expectation.provider_id!r} health is {health!r}."
|
||||||
|
),
|
||||||
|
object_ref=expectation.provider_id,
|
||||||
|
resolution="Restore provider health or use a documented degraded path.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
observed_authority_mode = str(state.get("authority_mode") or "")
|
||||||
|
if (
|
||||||
|
observed_authority_mode
|
||||||
|
and observed_authority_mode != expectation.authority_mode
|
||||||
|
):
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="external_provider_binding_authority_mismatch",
|
||||||
|
message=(
|
||||||
|
f"Provider {expectation.provider_id!r} is configured as "
|
||||||
|
f"{observed_authority_mode!r}; {expectation.authority_mode!r} "
|
||||||
|
"is required."
|
||||||
|
),
|
||||||
|
object_ref=expectation.provider_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if expectation.freshness_expectation is not None:
|
||||||
|
freshness = str(
|
||||||
|
state.get("freshness")
|
||||||
|
or state.get("freshness_state")
|
||||||
|
or "unknown"
|
||||||
|
)
|
||||||
|
if freshness != expectation.freshness_expectation:
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="external_provider_freshness_expectation_failed",
|
||||||
|
message=(
|
||||||
|
f"Provider {expectation.provider_id!r} freshness is "
|
||||||
|
f"{freshness!r}; {expectation.freshness_expectation!r} is required."
|
||||||
|
),
|
||||||
|
object_ref=expectation.provider_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if expectation.recovery_expectation is not None:
|
||||||
|
behavior = declaration.get("behavior")
|
||||||
|
declared_recovery = (
|
||||||
|
behavior.get(expectation.recovery_expectation)
|
||||||
|
if isinstance(behavior, Mapping)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
observed_recovery = state.get("recovery") or state.get(
|
||||||
|
"recovery_state"
|
||||||
|
)
|
||||||
|
if not declared_recovery and observed_recovery != expectation.recovery_expectation:
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="external_provider_recovery_expectation_failed",
|
||||||
|
message=(
|
||||||
|
f"Provider {expectation.provider_id!r} does not satisfy "
|
||||||
|
f"recovery expectation {expectation.recovery_expectation!r}."
|
||||||
|
),
|
||||||
|
object_ref=expectation.provider_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
def _dedupe_diagnostics(items: Sequence[ConfigurationDiagnostic]) -> list[ConfigurationDiagnostic]:
|
def _dedupe_diagnostics(items: Sequence[ConfigurationDiagnostic]) -> list[ConfigurationDiagnostic]:
|
||||||
seen: set[tuple[object, ...]] = set()
|
seen: set[tuple[object, ...]] = set()
|
||||||
result: list[ConfigurationDiagnostic] = []
|
result: list[ConfigurationDiagnostic] = []
|
||||||
|
|||||||
@@ -301,6 +301,18 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
|||||||
maintenance_required=True,
|
maintenance_required=True,
|
||||||
notes="This deployment-wide egress boundary remains out of band and applies to every connector worker.",
|
notes="This deployment-wide egress boundary remains out of band and applies to every connector worker.",
|
||||||
),
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
|
||||||
|
label="Installer-managed Garage trust",
|
||||||
|
owner_module="files",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Reserved for the exact installer-owned http://garage:3900 service; it must never authorize another S3 endpoint.",
|
||||||
|
),
|
||||||
ConfigurationFieldSafety(
|
ConfigurationFieldSafety(
|
||||||
key="GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
|
key="GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
|
||||||
label="Structured connector response limit",
|
label="Structured connector response limit",
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
CONNECTOR_RUNTIME_CONTRACT_VERSION = "1.0"
|
||||||
|
|
||||||
|
ConnectorDiagnosticSeverity = Literal["info", "warning", "error"]
|
||||||
|
ConnectorDiagnosticStage = Literal[
|
||||||
|
"configuration",
|
||||||
|
"authentication",
|
||||||
|
"discovery",
|
||||||
|
"read",
|
||||||
|
"mapping",
|
||||||
|
"planning",
|
||||||
|
"apply",
|
||||||
|
"reconciliation",
|
||||||
|
]
|
||||||
|
ConnectorEffectKind = Literal[
|
||||||
|
"create",
|
||||||
|
"update",
|
||||||
|
"delete",
|
||||||
|
"conflict",
|
||||||
|
"unchanged",
|
||||||
|
"ignored",
|
||||||
|
]
|
||||||
|
ConnectorOutcomeState = Literal[
|
||||||
|
"preview",
|
||||||
|
"accepted",
|
||||||
|
"rejected",
|
||||||
|
"outcome_unknown",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorContractError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectorEndpoint:
|
||||||
|
"""Sanitized endpoint identity. Credentials never belong in this value."""
|
||||||
|
|
||||||
|
url: str
|
||||||
|
credential_ref: str | None = None
|
||||||
|
tls_mode: Literal["required", "start_tls", "system", "disabled"] = "required"
|
||||||
|
options: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
normalized = self.url.strip()
|
||||||
|
parsed = urlsplit(normalized)
|
||||||
|
if not parsed.scheme or not parsed.hostname:
|
||||||
|
raise ConnectorContractError("Connector endpoints require an absolute URL.")
|
||||||
|
if parsed.username is not None or parsed.password is not None:
|
||||||
|
raise ConnectorContractError(
|
||||||
|
"Connector endpoint URLs must not contain credentials."
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "url", normalized)
|
||||||
|
if self.credential_ref is not None:
|
||||||
|
credential_ref = self.credential_ref.strip()
|
||||||
|
if not credential_ref:
|
||||||
|
raise ConnectorContractError("Credential references cannot be blank.")
|
||||||
|
object.__setattr__(self, "credential_ref", credential_ref)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectorDiagnostic:
|
||||||
|
severity: ConnectorDiagnosticSeverity
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
stage: ConnectorDiagnosticStage
|
||||||
|
retryable: bool = False
|
||||||
|
source_ref: str | None = None
|
||||||
|
object_ref: str | None = None
|
||||||
|
details: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.code.strip() or len(self.code) > 120:
|
||||||
|
raise ConnectorContractError(
|
||||||
|
"Connector diagnostic codes must contain 1 to 120 characters."
|
||||||
|
)
|
||||||
|
if not self.message.strip():
|
||||||
|
raise ConnectorContractError("Connector diagnostic messages cannot be blank.")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectorEffectPreview:
|
||||||
|
effect: ConnectorEffectKind
|
||||||
|
source_object_ref: str
|
||||||
|
target_object_ref: str | None = None
|
||||||
|
changed_fields: tuple[str, ...] = ()
|
||||||
|
sample: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
reason_code: str | None = None
|
||||||
|
outcome: ConnectorOutcomeState = "preview"
|
||||||
|
revision: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.source_object_ref.strip():
|
||||||
|
raise ConnectorContractError("Preview effects require a source object reference.")
|
||||||
|
if self.outcome != "preview":
|
||||||
|
raise ConnectorContractError("Dry-run effects must retain the preview outcome.")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectorEffectSummary:
|
||||||
|
creates: int = 0
|
||||||
|
updates: int = 0
|
||||||
|
deletes: int = 0
|
||||||
|
conflicts: int = 0
|
||||||
|
unchanged: int = 0
|
||||||
|
ignored: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> int:
|
||||||
|
return (
|
||||||
|
self.creates
|
||||||
|
+ self.updates
|
||||||
|
+ self.deletes
|
||||||
|
+ self.conflicts
|
||||||
|
+ self.unchanged
|
||||||
|
+ self.ignored
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectorDryRunRequest:
|
||||||
|
tenant_id: str
|
||||||
|
source_ref: str
|
||||||
|
force_full: bool = False
|
||||||
|
max_items: int = 1_000
|
||||||
|
expected_source_revision: str | None = None
|
||||||
|
context: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.tenant_id.strip() or not self.source_ref.strip():
|
||||||
|
raise ConnectorContractError("Dry runs require tenant and source references.")
|
||||||
|
if not 1 <= self.max_items <= 10_000:
|
||||||
|
raise ConnectorContractError("Dry-run max_items must be between 1 and 10000.")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectorDryRunResult:
|
||||||
|
contract_version: str
|
||||||
|
source_ref: str
|
||||||
|
source_revision: str
|
||||||
|
source_fingerprint: str
|
||||||
|
input_hash: str
|
||||||
|
generated_at: datetime
|
||||||
|
summary: ConnectorEffectSummary
|
||||||
|
effects: tuple[ConnectorEffectPreview, ...] = ()
|
||||||
|
diagnostics: tuple[ConnectorDiagnostic, ...] = ()
|
||||||
|
truncated: bool = False
|
||||||
|
stale: bool = False
|
||||||
|
apply_token: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.contract_version != CONNECTOR_RUNTIME_CONTRACT_VERSION:
|
||||||
|
raise ConnectorContractError(
|
||||||
|
f"Unsupported connector contract version: {self.contract_version!r}."
|
||||||
|
)
|
||||||
|
for name in ("source_ref", "source_revision", "source_fingerprint", "input_hash"):
|
||||||
|
if not str(getattr(self, name)).strip():
|
||||||
|
raise ConnectorContractError(f"Dry-run {name} cannot be blank.")
|
||||||
|
if self.summary.total != len(self.effects):
|
||||||
|
raise ConnectorContractError(
|
||||||
|
"Dry-run summary counts must match the returned effect list."
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_apply(self) -> bool:
|
||||||
|
return (
|
||||||
|
self.apply_token is not None
|
||||||
|
and not self.truncated
|
||||||
|
and not self.stale
|
||||||
|
and self.summary.conflicts == 0
|
||||||
|
and not any(item.severity == "error" for item in self.diagnostics)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_connector_effects(
|
||||||
|
effects: tuple[ConnectorEffectPreview, ...],
|
||||||
|
) -> ConnectorEffectSummary:
|
||||||
|
counts = Counter(item.effect for item in effects)
|
||||||
|
return ConnectorEffectSummary(
|
||||||
|
creates=counts["create"],
|
||||||
|
updates=counts["update"],
|
||||||
|
deletes=counts["delete"],
|
||||||
|
conflicts=counts["conflict"],
|
||||||
|
unchanged=counts["unchanged"],
|
||||||
|
ignored=counts["ignored"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ConnectorDryRunProvider(Protocol):
|
||||||
|
def preview(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ConnectorDryRunRequest,
|
||||||
|
) -> ConnectorDryRunResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CONNECTOR_RUNTIME_CONTRACT_VERSION",
|
||||||
|
"ConnectorContractError",
|
||||||
|
"ConnectorDiagnostic",
|
||||||
|
"ConnectorDiagnosticSeverity",
|
||||||
|
"ConnectorDiagnosticStage",
|
||||||
|
"ConnectorDryRunProvider",
|
||||||
|
"ConnectorDryRunRequest",
|
||||||
|
"ConnectorDryRunResult",
|
||||||
|
"ConnectorEffectKind",
|
||||||
|
"ConnectorEffectPreview",
|
||||||
|
"ConnectorEffectSummary",
|
||||||
|
"ConnectorEndpoint",
|
||||||
|
"ConnectorOutcomeState",
|
||||||
|
"summarize_connector_effects",
|
||||||
|
]
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from govoplan_core.core.distribution_lists import (
|
||||||
|
DistributionChannel,
|
||||||
|
DistributionExplanation,
|
||||||
|
DistributionOutcome,
|
||||||
|
DistributionSourceReference,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION = "addresses.contact_point_resolution"
|
||||||
|
CONTACT_POINT_CONTRACT_VERSION = "1.0"
|
||||||
|
|
||||||
|
ContactPointFallbackRule = Literal["none", "primary", "any"]
|
||||||
|
PostalAddressFormat = Literal["domestic", "international"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ContactPointResolutionRequest:
|
||||||
|
tenant_id: str
|
||||||
|
subject: DistributionSourceReference
|
||||||
|
effective_at: datetime
|
||||||
|
purpose: str | None = None
|
||||||
|
requested_channels: tuple[DistributionChannel, ...] = ()
|
||||||
|
address_purpose: str | None = None
|
||||||
|
fallback_rule: ContactPointFallbackRule = "primary"
|
||||||
|
locale: str | None = None
|
||||||
|
postal_format: PostalAddressFormat = "domestic"
|
||||||
|
context: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ContactPointCandidate:
|
||||||
|
channel: DistributionChannel
|
||||||
|
target: str
|
||||||
|
target_key: str
|
||||||
|
status: DistributionOutcome
|
||||||
|
contact_point_id: str | None = None
|
||||||
|
address_purpose: str | None = None
|
||||||
|
locale: str | None = None
|
||||||
|
preferred: bool = False
|
||||||
|
preference_rank: int | None = None
|
||||||
|
reason_code: str | None = None
|
||||||
|
explanation: str | None = None
|
||||||
|
source: DistributionSourceReference | None = None
|
||||||
|
source_revision: str | None = None
|
||||||
|
preference_revision: str | None = None
|
||||||
|
consent_revision: str | None = None
|
||||||
|
value: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ContactPointResolution:
|
||||||
|
contract_version: str
|
||||||
|
subject: DistributionSourceReference
|
||||||
|
status: DistributionOutcome
|
||||||
|
contact_id: str | None = None
|
||||||
|
display_name: str | None = None
|
||||||
|
candidates: tuple[ContactPointCandidate, ...] = ()
|
||||||
|
excluded: tuple[ContactPointCandidate, ...] = ()
|
||||||
|
explanations: tuple[DistributionExplanation, ...] = ()
|
||||||
|
source_revision: str | None = None
|
||||||
|
source_fingerprint: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ContactPointSourceRequest:
|
||||||
|
tenant_id: str
|
||||||
|
source_id: str
|
||||||
|
effective_at: datetime
|
||||||
|
purpose: str | None = None
|
||||||
|
requested_channels: tuple[DistributionChannel, ...] = ()
|
||||||
|
address_purpose: str | None = None
|
||||||
|
fallback_rule: ContactPointFallbackRule = "primary"
|
||||||
|
locale: str | None = None
|
||||||
|
postal_format: PostalAddressFormat = "domestic"
|
||||||
|
max_items: int = 5_000
|
||||||
|
context: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ContactPointSourcePreview:
|
||||||
|
contract_version: str
|
||||||
|
source: DistributionSourceReference
|
||||||
|
request: ContactPointSourceRequest
|
||||||
|
resolutions: tuple[ContactPointResolution, ...]
|
||||||
|
total_count: int
|
||||||
|
usable_count: int
|
||||||
|
excluded_count: int
|
||||||
|
offset: int
|
||||||
|
limit: int
|
||||||
|
has_more: bool
|
||||||
|
source_revision: str
|
||||||
|
source_fingerprint: str
|
||||||
|
generated_at: datetime
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ContactPointSnapshotRef:
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
contract_version: str
|
||||||
|
source: DistributionSourceReference
|
||||||
|
request: ContactPointSourceRequest
|
||||||
|
resolutions: tuple[ContactPointResolution, ...]
|
||||||
|
recipient_count: int
|
||||||
|
excluded_count: int
|
||||||
|
source_revision: str
|
||||||
|
source_fingerprint: str
|
||||||
|
snapshot_hash: str
|
||||||
|
generated_at: datetime
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ContactPointResolutionProvider(Protocol):
|
||||||
|
def resolve_contact_points(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ContactPointResolutionRequest,
|
||||||
|
) -> ContactPointResolution:
|
||||||
|
...
|
||||||
|
|
||||||
|
def preview_source(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ContactPointSourceRequest,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> ContactPointSourcePreview:
|
||||||
|
...
|
||||||
|
|
||||||
|
def freeze_source(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ContactPointSourceRequest,
|
||||||
|
) -> ContactPointSnapshotRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_snapshot(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
snapshot_id: str,
|
||||||
|
) -> ContactPointSnapshotRef | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def contact_point_resolution_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> ContactPointResolutionProvider | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION)
|
||||||
|
return capability if isinstance(capability, ContactPointResolutionProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION",
|
||||||
|
"CONTACT_POINT_CONTRACT_VERSION",
|
||||||
|
"ContactPointCandidate",
|
||||||
|
"ContactPointFallbackRule",
|
||||||
|
"ContactPointResolution",
|
||||||
|
"ContactPointResolutionProvider",
|
||||||
|
"ContactPointResolutionRequest",
|
||||||
|
"ContactPointSnapshotRef",
|
||||||
|
"ContactPointSourcePreview",
|
||||||
|
"ContactPointSourceRequest",
|
||||||
|
"PostalAddressFormat",
|
||||||
|
"contact_point_resolution_provider",
|
||||||
|
]
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from govoplan_core.core.automation import AutomationInvocation
|
||||||
|
from govoplan_core.core.events import PlatformEvent
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_DATAFLOW_RUN_LIFECYCLE = "dataflow.runLifecycle"
|
||||||
|
CAPABILITY_DATAFLOW_RUN_WORKER = "dataflow.runWorker"
|
||||||
|
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER = "dataflow.triggerDispatcher"
|
||||||
|
CAPABILITY_DATAFLOW_DATASET_OUTPUT = "dataflow.dataset_output"
|
||||||
|
|
||||||
|
|
||||||
|
class DataflowRunError(ValueError):
|
||||||
|
"""Stable base error for module-neutral Dataflow run operations."""
|
||||||
|
|
||||||
|
|
||||||
|
class DataflowRunNotFoundError(DataflowRunError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DataflowRunConflictError(DataflowRunError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DataflowRunUnavailableError(DataflowRunError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DataflowDatasetDescriptor:
|
||||||
|
pipeline_ref: str
|
||||||
|
name: str
|
||||||
|
revision: int
|
||||||
|
definition_hash: str
|
||||||
|
status: str
|
||||||
|
description: str | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
parameters: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DataflowDatasetRequest:
|
||||||
|
pipeline_ref: str
|
||||||
|
revision: int
|
||||||
|
parameters: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
row_limit: int = 500
|
||||||
|
expected_definition_hash: str | None = None
|
||||||
|
expected_source_fingerprints: tuple[Mapping[str, object], ...] = ()
|
||||||
|
run_ref: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DataflowDatasetResult:
|
||||||
|
pipeline_ref: str
|
||||||
|
revision: int
|
||||||
|
definition_hash: str
|
||||||
|
rows: tuple[Mapping[str, object], ...]
|
||||||
|
total_rows: int
|
||||||
|
truncated: bool
|
||||||
|
output_hash: str
|
||||||
|
executor_version: str
|
||||||
|
run_ref: str | None = None
|
||||||
|
source_fingerprints: tuple[Mapping[str, object], ...] = ()
|
||||||
|
diagnostics: tuple[Mapping[str, object], ...] = ()
|
||||||
|
generated_at: datetime | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DataflowDatasetOutputProvider(Protocol):
|
||||||
|
def list_outputs(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: str = "",
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[DataflowDatasetDescriptor]: ...
|
||||||
|
|
||||||
|
def read_output(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: DataflowDatasetRequest,
|
||||||
|
) -> DataflowDatasetResult: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DataflowPublicationTarget:
|
||||||
|
target_datasource_ref: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
source_name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
freeze: bool = False
|
||||||
|
frozen_label: str | None = None
|
||||||
|
set_current: bool = True
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DataflowRunRequest:
|
||||||
|
pipeline_ref: str
|
||||||
|
revision: int
|
||||||
|
idempotency_key: str
|
||||||
|
row_limit: int = 500
|
||||||
|
execution_backend: str = "auto"
|
||||||
|
environment: str = "development"
|
||||||
|
max_attempts: int = 3
|
||||||
|
retention_days: int = 30
|
||||||
|
publication: DataflowPublicationTarget | None = None
|
||||||
|
invocation: AutomationInvocation = field(
|
||||||
|
default_factory=AutomationInvocation
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DataflowRunDescriptor:
|
||||||
|
ref: str
|
||||||
|
pipeline_ref: str
|
||||||
|
revision: int
|
||||||
|
status: str
|
||||||
|
definition_hash: str
|
||||||
|
executor_version: str
|
||||||
|
input_row_count: int = 0
|
||||||
|
output_row_count: int = 0
|
||||||
|
output_publication_ref: str | None = None
|
||||||
|
output_datasource_ref: str | None = None
|
||||||
|
output_materialization_ref: str | None = None
|
||||||
|
invocation_kind: str = "manual"
|
||||||
|
trigger_ref: str | None = None
|
||||||
|
delivery_ref: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
started_at: datetime | None = None
|
||||||
|
finished_at: datetime | None = None
|
||||||
|
replayed: bool = False
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DataflowRunLifecycleProvider(Protocol):
|
||||||
|
def start_run(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: DataflowRunRequest,
|
||||||
|
) -> DataflowRunDescriptor:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_run(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
run_ref: str,
|
||||||
|
) -> DataflowRunDescriptor | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def cancel_run(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
run_ref: str,
|
||||||
|
) -> DataflowRunDescriptor:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DataflowTriggerDispatcher(Protocol):
|
||||||
|
def dispatch_due(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def ingest_event(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
event: PlatformEvent,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DataflowRunWorker(Protocol):
|
||||||
|
"""Durable worker boundary for queued Dataflow execution.
|
||||||
|
|
||||||
|
Implementations own claim transaction boundaries so a lease is committed
|
||||||
|
before potentially long-running execution starts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def dispatch_pending(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
limit: int = 10,
|
||||||
|
worker_id: str | None = None,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def purge_expired(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
limit: int = 500,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def dataflow_run_lifecycle(
|
||||||
|
registry: object | None,
|
||||||
|
) -> DataflowRunLifecycleProvider | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_DATAFLOW_RUN_LIFECYCLE)
|
||||||
|
return capability if isinstance(capability, DataflowRunLifecycleProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def dataflow_run_worker(
|
||||||
|
registry: object | None,
|
||||||
|
) -> DataflowRunWorker | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_DATAFLOW_RUN_WORKER)
|
||||||
|
return capability if isinstance(capability, DataflowRunWorker) else None
|
||||||
|
|
||||||
|
|
||||||
|
def dataflow_trigger_dispatcher(
|
||||||
|
registry: object | None,
|
||||||
|
) -> DataflowTriggerDispatcher | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER)
|
||||||
|
return (
|
||||||
|
capability
|
||||||
|
if isinstance(capability, DataflowTriggerDispatcher)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dataflow_dataset_output(
|
||||||
|
registry: object | None,
|
||||||
|
) -> DataflowDatasetOutputProvider | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_DATAFLOW_DATASET_OUTPUT)
|
||||||
|
return capability if isinstance(capability, DataflowDatasetOutputProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _capability(registry: object | None, name: str) -> object | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(name)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return registry.capability(name)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_DATAFLOW_RUN_LIFECYCLE",
|
||||||
|
"CAPABILITY_DATAFLOW_RUN_WORKER",
|
||||||
|
"CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER",
|
||||||
|
"CAPABILITY_DATAFLOW_DATASET_OUTPUT",
|
||||||
|
"DataflowDatasetDescriptor",
|
||||||
|
"DataflowDatasetOutputProvider",
|
||||||
|
"DataflowDatasetRequest",
|
||||||
|
"DataflowDatasetResult",
|
||||||
|
"DataflowPublicationTarget",
|
||||||
|
"DataflowRunConflictError",
|
||||||
|
"DataflowRunDescriptor",
|
||||||
|
"DataflowRunError",
|
||||||
|
"DataflowRunLifecycleProvider",
|
||||||
|
"DataflowRunNotFoundError",
|
||||||
|
"DataflowRunRequest",
|
||||||
|
"DataflowRunUnavailableError",
|
||||||
|
"DataflowRunWorker",
|
||||||
|
"DataflowTriggerDispatcher",
|
||||||
|
"dataflow_run_lifecycle",
|
||||||
|
"dataflow_run_worker",
|
||||||
|
"dataflow_trigger_dispatcher",
|
||||||
|
"dataflow_dataset_output",
|
||||||
|
]
|
||||||
@@ -0,0 +1,793 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.external_references import (
|
||||||
|
SOURCE_AUTHORITY_MODES,
|
||||||
|
SourceAuthorityMode,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.tabular_sources import (
|
||||||
|
DEFAULT_PREVIEW_BYTES,
|
||||||
|
DEFAULT_PREVIEW_TIMEOUT_MS,
|
||||||
|
TabularPreviewDiagnostic,
|
||||||
|
TabularPushdown,
|
||||||
|
TabularSourceHealth,
|
||||||
|
TabularSourceMode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_DATASOURCE_CATALOGUE = "datasources.catalogue"
|
||||||
|
CAPABILITY_DATASOURCE_LIFECYCLE = "datasources.lifecycle"
|
||||||
|
CAPABILITY_DATASOURCE_PUBLICATION = "datasources.publication"
|
||||||
|
CAPABILITY_DATASOURCE_ORIGINS = "connectors.datasourceOrigins"
|
||||||
|
CAPABILITY_DATASOURCE_ARTIFACT_BACKENDS = "datasources.artifactBackends"
|
||||||
|
CAPABILITY_POLICY_DATASOURCE_VISIBILITY = "policy.datasourceVisibility"
|
||||||
|
|
||||||
|
DatasourceMode = Literal["live", "cached", "static"]
|
||||||
|
DatasourceKind = Literal[
|
||||||
|
"upload",
|
||||||
|
"database",
|
||||||
|
"http",
|
||||||
|
"rest",
|
||||||
|
"directory",
|
||||||
|
"file",
|
||||||
|
"feed",
|
||||||
|
"custom",
|
||||||
|
]
|
||||||
|
DatasourceShape = Literal["tabular", "document", "binary", "directory", "stream"]
|
||||||
|
DatasourceConsistency = Literal["current", "live", "frozen"]
|
||||||
|
DatasourceVisibilityAction = Literal["discover", "read"]
|
||||||
|
DatasourcePublicationStatus = Literal[
|
||||||
|
"published",
|
||||||
|
"published_with_warnings",
|
||||||
|
"review_required",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceError(ValueError):
|
||||||
|
"""Stable base error for provider-neutral datasource operations."""
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceNotFoundError(DatasourceError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceAccessError(DatasourceError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceValidationError(DatasourceError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceUnavailableError(DatasourceError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceField:
|
||||||
|
name: str
|
||||||
|
data_type: str
|
||||||
|
nullable: bool = True
|
||||||
|
classification: str = "internal"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceGovernance:
|
||||||
|
"""Provider-neutral governance facts attached to a datasource revision."""
|
||||||
|
|
||||||
|
owner_ref: str | None = None
|
||||||
|
steward_ref: str | None = None
|
||||||
|
responsible_organization_ref: str | None = None
|
||||||
|
responsible_function_ref: str | None = None
|
||||||
|
authoritative_source_ref: str | None = None
|
||||||
|
authority_mode: SourceAuthorityMode = "linked_reference"
|
||||||
|
legal_basis_refs: tuple[str, ...] = ()
|
||||||
|
purposes: tuple[str, ...] = ()
|
||||||
|
semantic_definition: str | None = None
|
||||||
|
schema_owner_ref: str | None = None
|
||||||
|
official_keys: tuple[str, ...] = ()
|
||||||
|
classification: str = "internal"
|
||||||
|
privacy_profile_ref: str | None = None
|
||||||
|
retention_policy_ref: str | None = None
|
||||||
|
access_policy_ref: str | None = None
|
||||||
|
visibility_policy: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
hold_refs: tuple[str, ...] = ()
|
||||||
|
publication_state: str = "draft"
|
||||||
|
transfer_agreement_ref: str | None = None
|
||||||
|
freshness_policy: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
quality_policy: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
known_limits: tuple[str, ...] = ()
|
||||||
|
correction_procedure_ref: str | None = None
|
||||||
|
affected_refs: tuple[str, ...] = ()
|
||||||
|
dependency_refs: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.authority_mode not in SOURCE_AUTHORITY_MODES:
|
||||||
|
raise DatasourceValidationError(
|
||||||
|
f"Unsupported datasource authority mode: {self.authority_mode!r}."
|
||||||
|
)
|
||||||
|
if not self.classification.strip():
|
||||||
|
raise DatasourceValidationError("Datasource classification is required.")
|
||||||
|
if not self.publication_state.strip():
|
||||||
|
raise DatasourceValidationError("Datasource publication state is required.")
|
||||||
|
for field_name in (
|
||||||
|
"legal_basis_refs",
|
||||||
|
"purposes",
|
||||||
|
"official_keys",
|
||||||
|
"hold_refs",
|
||||||
|
"known_limits",
|
||||||
|
"affected_refs",
|
||||||
|
"dependency_refs",
|
||||||
|
):
|
||||||
|
values = getattr(self, field_name)
|
||||||
|
if any(not value.strip() for value in values):
|
||||||
|
raise DatasourceValidationError(
|
||||||
|
f"Datasource governance {field_name} cannot contain empty values."
|
||||||
|
)
|
||||||
|
if len(values) != len(set(values)):
|
||||||
|
raise DatasourceValidationError(
|
||||||
|
f"Datasource governance {field_name} cannot contain duplicates."
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_mapping(cls, value: Mapping[str, object] | None) -> "DatasourceGovernance":
|
||||||
|
source = value or {}
|
||||||
|
return cls(
|
||||||
|
owner_ref=_optional_governance_text(source.get("owner_ref")),
|
||||||
|
steward_ref=_optional_governance_text(source.get("steward_ref")),
|
||||||
|
responsible_organization_ref=_optional_governance_text(
|
||||||
|
source.get("responsible_organization_ref")
|
||||||
|
),
|
||||||
|
responsible_function_ref=_optional_governance_text(
|
||||||
|
source.get("responsible_function_ref")
|
||||||
|
),
|
||||||
|
authoritative_source_ref=_optional_governance_text(
|
||||||
|
source.get("authoritative_source_ref")
|
||||||
|
),
|
||||||
|
authority_mode=str(
|
||||||
|
source.get("authority_mode") or "linked_reference"
|
||||||
|
), # type: ignore[arg-type]
|
||||||
|
legal_basis_refs=_governance_texts(source.get("legal_basis_refs")),
|
||||||
|
purposes=_governance_texts(source.get("purposes")),
|
||||||
|
semantic_definition=_optional_governance_text(
|
||||||
|
source.get("semantic_definition")
|
||||||
|
),
|
||||||
|
schema_owner_ref=_optional_governance_text(source.get("schema_owner_ref")),
|
||||||
|
official_keys=_governance_texts(source.get("official_keys")),
|
||||||
|
classification=str(source.get("classification") or "internal"),
|
||||||
|
privacy_profile_ref=_optional_governance_text(
|
||||||
|
source.get("privacy_profile_ref")
|
||||||
|
),
|
||||||
|
retention_policy_ref=_optional_governance_text(
|
||||||
|
source.get("retention_policy_ref")
|
||||||
|
),
|
||||||
|
access_policy_ref=_optional_governance_text(
|
||||||
|
source.get("access_policy_ref")
|
||||||
|
),
|
||||||
|
visibility_policy=_governance_mapping(
|
||||||
|
source.get("visibility_policy")
|
||||||
|
),
|
||||||
|
hold_refs=_governance_texts(source.get("hold_refs")),
|
||||||
|
publication_state=str(source.get("publication_state") or "draft"),
|
||||||
|
transfer_agreement_ref=_optional_governance_text(
|
||||||
|
source.get("transfer_agreement_ref")
|
||||||
|
),
|
||||||
|
freshness_policy=_governance_mapping(source.get("freshness_policy")),
|
||||||
|
quality_policy=_governance_mapping(source.get("quality_policy")),
|
||||||
|
known_limits=_governance_texts(source.get("known_limits")),
|
||||||
|
correction_procedure_ref=_optional_governance_text(
|
||||||
|
source.get("correction_procedure_ref")
|
||||||
|
),
|
||||||
|
affected_refs=_governance_texts(source.get("affected_refs")),
|
||||||
|
dependency_refs=_governance_texts(source.get("dependency_refs")),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"owner_ref": self.owner_ref,
|
||||||
|
"steward_ref": self.steward_ref,
|
||||||
|
"responsible_organization_ref": self.responsible_organization_ref,
|
||||||
|
"responsible_function_ref": self.responsible_function_ref,
|
||||||
|
"authoritative_source_ref": self.authoritative_source_ref,
|
||||||
|
"authority_mode": self.authority_mode,
|
||||||
|
"legal_basis_refs": list(self.legal_basis_refs),
|
||||||
|
"purposes": list(self.purposes),
|
||||||
|
"semantic_definition": self.semantic_definition,
|
||||||
|
"schema_owner_ref": self.schema_owner_ref,
|
||||||
|
"official_keys": list(self.official_keys),
|
||||||
|
"classification": self.classification,
|
||||||
|
"privacy_profile_ref": self.privacy_profile_ref,
|
||||||
|
"retention_policy_ref": self.retention_policy_ref,
|
||||||
|
"access_policy_ref": self.access_policy_ref,
|
||||||
|
"visibility_policy": dict(self.visibility_policy),
|
||||||
|
"hold_refs": list(self.hold_refs),
|
||||||
|
"publication_state": self.publication_state,
|
||||||
|
"transfer_agreement_ref": self.transfer_agreement_ref,
|
||||||
|
"freshness_policy": dict(self.freshness_policy),
|
||||||
|
"quality_policy": dict(self.quality_policy),
|
||||||
|
"known_limits": list(self.known_limits),
|
||||||
|
"correction_procedure_ref": self.correction_procedure_ref,
|
||||||
|
"affected_refs": list(self.affected_refs),
|
||||||
|
"dependency_refs": list(self.dependency_refs),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceVisibilityPolicyRequest:
|
||||||
|
tenant_id: str
|
||||||
|
datasource_ref: str
|
||||||
|
principal: PrincipalRef
|
||||||
|
action: DatasourceVisibilityAction
|
||||||
|
classification: str = "internal"
|
||||||
|
policy_ref: str | None = None
|
||||||
|
consistency: DatasourceConsistency = "current"
|
||||||
|
materialization_ref: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceVisibilityPolicyDecision:
|
||||||
|
allowed: bool
|
||||||
|
reason: str | None = None
|
||||||
|
policies: tuple[Mapping[str, object], ...] = ()
|
||||||
|
decision_ref: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DatasourceVisibilityPolicyProvider(Protocol):
|
||||||
|
"""Optionally tighten Datasources-owned local visibility policy."""
|
||||||
|
|
||||||
|
def decide_datasource_visibility(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
request: DatasourceVisibilityPolicyRequest,
|
||||||
|
) -> DatasourceVisibilityPolicyDecision: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceDescriptor:
|
||||||
|
ref: str
|
||||||
|
source_name: str
|
||||||
|
name: str
|
||||||
|
kind: DatasourceKind
|
||||||
|
mode: DatasourceMode
|
||||||
|
shape: DatasourceShape
|
||||||
|
status: str = "active"
|
||||||
|
description: str | None = None
|
||||||
|
provider: str | None = None
|
||||||
|
provider_ref: str | None = None
|
||||||
|
schema: tuple[DatasourceField, ...] = ()
|
||||||
|
schema_version: str = "1"
|
||||||
|
fingerprint: str = ""
|
||||||
|
current_materialization_ref: str | None = None
|
||||||
|
row_count: int | None = None
|
||||||
|
byte_count: int | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
capabilities: tuple[str, ...] = ("read",)
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
governance: DatasourceGovernance = field(default_factory=DatasourceGovernance)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceMaterialization:
|
||||||
|
ref: str
|
||||||
|
datasource_ref: str
|
||||||
|
revision: int
|
||||||
|
state: str
|
||||||
|
fingerprint: str
|
||||||
|
schema: tuple[DatasourceField, ...] = ()
|
||||||
|
row_count: int | None = None
|
||||||
|
byte_count: int | None = None
|
||||||
|
frozen_at: datetime | None = None
|
||||||
|
frozen_label: str | None = None
|
||||||
|
source_timestamp: datetime | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
governance: DatasourceGovernance = field(default_factory=DatasourceGovernance)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceStage:
|
||||||
|
ref: str
|
||||||
|
name: str
|
||||||
|
source_name: str
|
||||||
|
kind: DatasourceKind
|
||||||
|
mode: DatasourceMode
|
||||||
|
shape: DatasourceShape
|
||||||
|
state: str
|
||||||
|
target_datasource_ref: str | None = None
|
||||||
|
fingerprint: str = ""
|
||||||
|
schema: tuple[DatasourceField, ...] = ()
|
||||||
|
row_count: int | None = None
|
||||||
|
byte_count: int | None = None
|
||||||
|
validation: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
created_at: datetime | None = None
|
||||||
|
promoted_at: datetime | None = None
|
||||||
|
promoted_materialization_ref: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
governance: DatasourceGovernance = field(default_factory=DatasourceGovernance)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceReadRequest:
|
||||||
|
datasource_ref: str
|
||||||
|
materialization_ref: str | None = None
|
||||||
|
consistency: DatasourceConsistency = "current"
|
||||||
|
limit: int = 250
|
||||||
|
offset: int = 0
|
||||||
|
columns: tuple[str, ...] = ()
|
||||||
|
expected_fingerprint: str | None = None
|
||||||
|
max_bytes: int = DEFAULT_PREVIEW_BYTES
|
||||||
|
timeout_ms: int = DEFAULT_PREVIEW_TIMEOUT_MS
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceReadResult:
|
||||||
|
datasource: DatasourceDescriptor
|
||||||
|
rows: tuple[Mapping[str, object], ...]
|
||||||
|
total_rows: int
|
||||||
|
truncated: bool
|
||||||
|
materialization: DatasourceMaterialization | None = None
|
||||||
|
returned_bytes: int = 0
|
||||||
|
elapsed_ms: int = 0
|
||||||
|
effective_row_limit: int = 0
|
||||||
|
effective_byte_limit: int = 0
|
||||||
|
effective_timeout_ms: int = 0
|
||||||
|
diagnostics: tuple[TabularPreviewDiagnostic, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceStageInput:
|
||||||
|
name: str
|
||||||
|
source_name: str
|
||||||
|
kind: DatasourceKind
|
||||||
|
mode: DatasourceMode
|
||||||
|
shape: DatasourceShape
|
||||||
|
rows: tuple[Mapping[str, object], ...]
|
||||||
|
description: str | None = None
|
||||||
|
target_datasource_ref: str | None = None
|
||||||
|
provider: str | None = None
|
||||||
|
provider_ref: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
governance: DatasourceGovernance | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceArtifactReference:
|
||||||
|
"""Immutable provider-neutral reference to a durable tabular payload.
|
||||||
|
|
||||||
|
The producer owns creation of the payload. Datasources pins its locator,
|
||||||
|
checksum and declared shape without importing the artifact-owning module;
|
||||||
|
a configured payload backend verifies integrity and provides bounded reads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
backend: str
|
||||||
|
locator: str
|
||||||
|
checksum: str
|
||||||
|
row_count: int
|
||||||
|
byte_count: int
|
||||||
|
schema: tuple[DatasourceField, ...]
|
||||||
|
fingerprint: str
|
||||||
|
media_type: str = "application/x-ndjson"
|
||||||
|
checkpoint: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
validation: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DatasourceArtifactBackend(Protocol):
|
||||||
|
"""Storage-module boundary for immutable artifact-backed tabular data."""
|
||||||
|
|
||||||
|
backend: str
|
||||||
|
|
||||||
|
def verify(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
artifact: DatasourceArtifactReference,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
def read_rows(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
artifact: DatasourceArtifactReference,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
) -> Sequence[Mapping[str, object]]: ...
|
||||||
|
|
||||||
|
def delete(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
artifact: DatasourceArtifactReference,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DatasourceArtifactBackendProvider(Protocol):
|
||||||
|
def artifact_backends(self) -> Sequence[DatasourceArtifactBackend]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourcePublicationRequest:
|
||||||
|
producer_module: str
|
||||||
|
producer_run_ref: str
|
||||||
|
idempotency_key: str
|
||||||
|
rows: tuple[Mapping[str, object], ...] | None = None
|
||||||
|
artifact: DatasourceArtifactReference | None = None
|
||||||
|
target_datasource_ref: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
source_name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
freeze: bool = False
|
||||||
|
frozen_label: str | None = None
|
||||||
|
set_current: bool = True
|
||||||
|
source_timestamp: datetime | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
governance: DatasourceGovernance | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourcePublicationResult:
|
||||||
|
ref: str
|
||||||
|
status: DatasourcePublicationStatus
|
||||||
|
datasource: DatasourceDescriptor
|
||||||
|
materialization: DatasourceMaterialization
|
||||||
|
replayed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceOrigin:
|
||||||
|
ref: str
|
||||||
|
source_name: str
|
||||||
|
name: str
|
||||||
|
kind: DatasourceKind
|
||||||
|
shape: DatasourceShape
|
||||||
|
supported_modes: tuple[DatasourceMode, ...]
|
||||||
|
provider: str
|
||||||
|
description: str | None = None
|
||||||
|
schema: tuple[DatasourceField, ...] = ()
|
||||||
|
schema_version: str = "1"
|
||||||
|
fingerprint: str = ""
|
||||||
|
row_count: int | None = None
|
||||||
|
byte_count: int | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
capabilities: tuple[str, ...] = ("read",)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
source_mode: TabularSourceMode = "cached"
|
||||||
|
pushdown: TabularPushdown = field(default_factory=TabularPushdown)
|
||||||
|
health: TabularSourceHealth = field(default_factory=TabularSourceHealth)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceOriginReadRequest:
|
||||||
|
origin_ref: str
|
||||||
|
limit: int = 250
|
||||||
|
offset: int = 0
|
||||||
|
columns: tuple[str, ...] = ()
|
||||||
|
expected_fingerprint: str | None = None
|
||||||
|
max_bytes: int = DEFAULT_PREVIEW_BYTES
|
||||||
|
timeout_ms: int = DEFAULT_PREVIEW_TIMEOUT_MS
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceOriginReadResult:
|
||||||
|
origin: DatasourceOrigin
|
||||||
|
rows: tuple[Mapping[str, object], ...]
|
||||||
|
total_rows: int
|
||||||
|
truncated: bool
|
||||||
|
returned_bytes: int = 0
|
||||||
|
elapsed_ms: int = 0
|
||||||
|
effective_row_limit: int = 0
|
||||||
|
effective_byte_limit: int = 0
|
||||||
|
effective_timeout_ms: int = 0
|
||||||
|
diagnostics: tuple[TabularPreviewDiagnostic, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DatasourceCatalogueProvider(Protocol):
|
||||||
|
def list_datasources(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: str = "",
|
||||||
|
limit: int = 100,
|
||||||
|
authority_mode: str | None = None,
|
||||||
|
classification: str | None = None,
|
||||||
|
publication_state: str | None = None,
|
||||||
|
owner_ref: str | None = None,
|
||||||
|
responsible_organization_ref: str | None = None,
|
||||||
|
affected_ref: str | None = None,
|
||||||
|
dependency_ref: str | None = None,
|
||||||
|
) -> Sequence[DatasourceDescriptor]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_datasource(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
datasource_ref: str,
|
||||||
|
) -> DatasourceDescriptor | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def read_datasource(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: DatasourceReadRequest,
|
||||||
|
) -> DatasourceReadResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
def list_materializations(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
datasource_ref: str,
|
||||||
|
) -> Sequence[DatasourceMaterialization]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DatasourceLifecycleProvider(Protocol):
|
||||||
|
def list_stages(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[DatasourceStage]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def create_stage(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
stage: DatasourceStageInput,
|
||||||
|
) -> DatasourceStage:
|
||||||
|
...
|
||||||
|
|
||||||
|
def promote_stage(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
stage_ref: str,
|
||||||
|
freeze: bool = False,
|
||||||
|
frozen_label: str | None = None,
|
||||||
|
) -> tuple[DatasourceDescriptor, DatasourceMaterialization]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def register_origin(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
origin_ref: str,
|
||||||
|
name: str,
|
||||||
|
source_name: str,
|
||||||
|
mode: DatasourceMode,
|
||||||
|
description: str | None = None,
|
||||||
|
governance: DatasourceGovernance | None = None,
|
||||||
|
) -> DatasourceDescriptor:
|
||||||
|
...
|
||||||
|
|
||||||
|
def update_datasource_governance(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
datasource_ref: str,
|
||||||
|
governance: DatasourceGovernance,
|
||||||
|
) -> DatasourceDescriptor:
|
||||||
|
...
|
||||||
|
|
||||||
|
def refresh_datasource(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
datasource_ref: str,
|
||||||
|
) -> tuple[DatasourceDescriptor, DatasourceMaterialization]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def freeze_datasource(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
datasource_ref: str,
|
||||||
|
label: str | None = None,
|
||||||
|
) -> DatasourceMaterialization:
|
||||||
|
...
|
||||||
|
|
||||||
|
def retire_datasource(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
datasource_ref: str,
|
||||||
|
) -> DatasourceDescriptor:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DatasourcePublicationProvider(Protocol):
|
||||||
|
def publish_rows(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: DatasourcePublicationRequest,
|
||||||
|
) -> DatasourcePublicationResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DatasourceOriginProvider(Protocol):
|
||||||
|
def list_origins(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: str = "",
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[DatasourceOrigin]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_origin(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
origin_ref: str,
|
||||||
|
) -> DatasourceOrigin | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def read_origin(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: DatasourceOriginReadRequest,
|
||||||
|
) -> DatasourceOriginReadResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def datasource_catalogue(registry: object | None) -> DatasourceCatalogueProvider | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_DATASOURCE_CATALOGUE)
|
||||||
|
return capability if isinstance(capability, DatasourceCatalogueProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def datasource_artifact_backend_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> DatasourceArtifactBackendProvider | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_DATASOURCE_ARTIFACT_BACKENDS)
|
||||||
|
return (
|
||||||
|
capability
|
||||||
|
if isinstance(capability, DatasourceArtifactBackendProvider)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def datasource_lifecycle(registry: object | None) -> DatasourceLifecycleProvider | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_DATASOURCE_LIFECYCLE)
|
||||||
|
return capability if isinstance(capability, DatasourceLifecycleProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def datasource_publication(
|
||||||
|
registry: object | None,
|
||||||
|
) -> DatasourcePublicationProvider | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_DATASOURCE_PUBLICATION)
|
||||||
|
return capability if isinstance(capability, DatasourcePublicationProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def datasource_origins(registry: object | None) -> DatasourceOriginProvider | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_DATASOURCE_ORIGINS)
|
||||||
|
return capability if isinstance(capability, DatasourceOriginProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def datasource_visibility_policy_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> DatasourceVisibilityPolicyProvider | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_POLICY_DATASOURCE_VISIBILITY)
|
||||||
|
return capability if isinstance(capability, DatasourceVisibilityPolicyProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _capability(registry: object | None, name: str) -> object | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(name)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return registry.capability(name)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_governance_text(value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
cleaned = str(value).strip()
|
||||||
|
return cleaned or None
|
||||||
|
|
||||||
|
|
||||||
|
def _governance_texts(value: object) -> tuple[str, ...]:
|
||||||
|
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||||
|
return ()
|
||||||
|
return tuple(str(item).strip() for item in value)
|
||||||
|
|
||||||
|
|
||||||
|
def _governance_mapping(value: object) -> Mapping[str, object]:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
return {}
|
||||||
|
return {str(key): item for key, item in value.items()}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_DATASOURCE_CATALOGUE",
|
||||||
|
"CAPABILITY_DATASOURCE_ARTIFACT_BACKENDS",
|
||||||
|
"CAPABILITY_DATASOURCE_LIFECYCLE",
|
||||||
|
"CAPABILITY_DATASOURCE_ORIGINS",
|
||||||
|
"CAPABILITY_DATASOURCE_PUBLICATION",
|
||||||
|
"CAPABILITY_POLICY_DATASOURCE_VISIBILITY",
|
||||||
|
"DatasourceAccessError",
|
||||||
|
"DatasourceArtifactReference",
|
||||||
|
"DatasourceArtifactBackend",
|
||||||
|
"DatasourceArtifactBackendProvider",
|
||||||
|
"DatasourceCatalogueProvider",
|
||||||
|
"DatasourceConsistency",
|
||||||
|
"DatasourceDescriptor",
|
||||||
|
"DatasourceError",
|
||||||
|
"DatasourceField",
|
||||||
|
"DatasourceGovernance",
|
||||||
|
"DatasourceKind",
|
||||||
|
"DatasourceLifecycleProvider",
|
||||||
|
"DatasourceMaterialization",
|
||||||
|
"DatasourceMode",
|
||||||
|
"DatasourceNotFoundError",
|
||||||
|
"DatasourceOrigin",
|
||||||
|
"DatasourceOriginProvider",
|
||||||
|
"DatasourceOriginReadRequest",
|
||||||
|
"DatasourceOriginReadResult",
|
||||||
|
"DatasourcePublicationProvider",
|
||||||
|
"DatasourcePublicationRequest",
|
||||||
|
"DatasourcePublicationResult",
|
||||||
|
"DatasourcePublicationStatus",
|
||||||
|
"DatasourceReadRequest",
|
||||||
|
"DatasourceReadResult",
|
||||||
|
"DatasourceShape",
|
||||||
|
"DatasourceStage",
|
||||||
|
"DatasourceStageInput",
|
||||||
|
"DatasourceUnavailableError",
|
||||||
|
"DatasourceValidationError",
|
||||||
|
"DatasourceVisibilityAction",
|
||||||
|
"DatasourceVisibilityPolicyDecision",
|
||||||
|
"DatasourceVisibilityPolicyProvider",
|
||||||
|
"DatasourceVisibilityPolicyRequest",
|
||||||
|
"datasource_catalogue",
|
||||||
|
"datasource_artifact_backend_provider",
|
||||||
|
"datasource_lifecycle",
|
||||||
|
"datasource_origins",
|
||||||
|
"datasource_publication",
|
||||||
|
"datasource_visibility_policy_provider",
|
||||||
|
]
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionPort:
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
required: bool = True
|
||||||
|
multiple: bool = False
|
||||||
|
minimum_connections: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionConfigField:
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
kind: str
|
||||||
|
required: bool = False
|
||||||
|
description: str | None = None
|
||||||
|
options: tuple[tuple[str, str], ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionNodeType:
|
||||||
|
type: str
|
||||||
|
category: str
|
||||||
|
label: str
|
||||||
|
description: str
|
||||||
|
icon: str
|
||||||
|
input_ports: tuple[DefinitionPort, ...] = ()
|
||||||
|
output_ports: tuple[DefinitionPort, ...] = (
|
||||||
|
DefinitionPort(id="output", label="Output"),
|
||||||
|
)
|
||||||
|
config_fields: tuple[DefinitionConfigField, ...] = ()
|
||||||
|
default_config: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionNode:
|
||||||
|
id: str
|
||||||
|
type: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionEdge:
|
||||||
|
id: str
|
||||||
|
source: str
|
||||||
|
target: str
|
||||||
|
source_port: str = "output"
|
||||||
|
target_port: str = "input"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionNodeCountConstraint:
|
||||||
|
code: str
|
||||||
|
label: str
|
||||||
|
minimum: int = 0
|
||||||
|
maximum: int | None = None
|
||||||
|
node_types: tuple[str, ...] = ()
|
||||||
|
type_prefixes: tuple[str, ...] = ()
|
||||||
|
categories: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
def matches(self, node: DefinitionNode, node_type: DefinitionNodeType | None) -> bool:
|
||||||
|
return (
|
||||||
|
node.type in self.node_types
|
||||||
|
or any(node.type.startswith(prefix) for prefix in self.type_prefixes)
|
||||||
|
or node_type is not None
|
||||||
|
and node_type.category in self.categories
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionGraphConstraints:
|
||||||
|
max_nodes: int = 100
|
||||||
|
max_edges: int = 200
|
||||||
|
allow_cycles: bool = False
|
||||||
|
require_connected: bool = True
|
||||||
|
node_counts: tuple[DefinitionNodeCountConstraint, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionGraphLibrary:
|
||||||
|
id: str
|
||||||
|
version: str
|
||||||
|
category_labels: Mapping[str, str]
|
||||||
|
node_types: tuple[DefinitionNodeType, ...]
|
||||||
|
constraints: DefinitionGraphConstraints = field(default_factory=DefinitionGraphConstraints)
|
||||||
|
|
||||||
|
def node_type(self, type_id: str) -> DefinitionNodeType | None:
|
||||||
|
return next((item for item in self.node_types if item.type == type_id), None)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionDiagnostic:
|
||||||
|
severity: str
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
node_id: str | None = None
|
||||||
|
field: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_definition_graph(
|
||||||
|
library: DefinitionGraphLibrary,
|
||||||
|
*,
|
||||||
|
nodes: Sequence[DefinitionNode],
|
||||||
|
edges: Sequence[DefinitionEdge],
|
||||||
|
) -> tuple[DefinitionDiagnostic, ...]:
|
||||||
|
if not nodes:
|
||||||
|
return (_error("graph.empty", "Add nodes before saving the definition."),)
|
||||||
|
constraints = library.constraints
|
||||||
|
diagnostics = _graph_size_diagnostics(
|
||||||
|
node_count=len(nodes),
|
||||||
|
edge_count=len(edges),
|
||||||
|
constraints=constraints,
|
||||||
|
)
|
||||||
|
node_by_id = {node.id: node for node in nodes}
|
||||||
|
definitions: dict[str, DefinitionNodeType] = {}
|
||||||
|
for item in library.node_types:
|
||||||
|
definitions.setdefault(item.type, item)
|
||||||
|
diagnostics.extend(_identifier_diagnostics(nodes=nodes, edges=edges))
|
||||||
|
incoming, undirected, topology_edges, edge_diagnostics = (
|
||||||
|
_validate_definition_edges(
|
||||||
|
edges=edges,
|
||||||
|
node_by_id=node_by_id,
|
||||||
|
definitions=definitions,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
diagnostics.extend(edge_diagnostics)
|
||||||
|
diagnostics.extend(
|
||||||
|
_node_port_diagnostics(
|
||||||
|
nodes=nodes,
|
||||||
|
definitions=definitions,
|
||||||
|
incoming=incoming,
|
||||||
|
library_id=library.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
diagnostics.extend(
|
||||||
|
_node_count_diagnostics(
|
||||||
|
nodes=nodes,
|
||||||
|
definitions=definitions,
|
||||||
|
constraints=constraints,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
diagnostics.extend(
|
||||||
|
_topology_diagnostics(
|
||||||
|
nodes=nodes,
|
||||||
|
node_by_id=node_by_id,
|
||||||
|
topology_edges=topology_edges,
|
||||||
|
undirected=undirected,
|
||||||
|
constraints=constraints,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(diagnostics)
|
||||||
|
|
||||||
|
|
||||||
|
def _graph_size_diagnostics(
|
||||||
|
*,
|
||||||
|
node_count: int,
|
||||||
|
edge_count: int,
|
||||||
|
constraints: DefinitionGraphConstraints,
|
||||||
|
) -> list[DefinitionDiagnostic]:
|
||||||
|
diagnostics: list[DefinitionDiagnostic] = []
|
||||||
|
if node_count > constraints.max_nodes:
|
||||||
|
diagnostics.append(
|
||||||
|
_error(
|
||||||
|
"graph.node_limit",
|
||||||
|
f"Definitions are limited to {constraints.max_nodes:,} nodes.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if edge_count > constraints.max_edges:
|
||||||
|
diagnostics.append(
|
||||||
|
_error(
|
||||||
|
"graph.edge_limit",
|
||||||
|
f"Definitions are limited to {constraints.max_edges:,} edges.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _identifier_diagnostics(
|
||||||
|
*,
|
||||||
|
nodes: Sequence[DefinitionNode],
|
||||||
|
edges: Sequence[DefinitionEdge],
|
||||||
|
) -> list[DefinitionDiagnostic]:
|
||||||
|
diagnostics: list[DefinitionDiagnostic] = []
|
||||||
|
if len({node.id for node in nodes}) != len(nodes):
|
||||||
|
diagnostics.append(_error("graph.duplicate_node", "Node identifiers must be unique."))
|
||||||
|
if len({edge.id for edge in edges}) != len(edges):
|
||||||
|
diagnostics.append(_error("graph.duplicate_edge", "Edge identifiers must be unique."))
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_definition_edges(
|
||||||
|
*,
|
||||||
|
edges: Sequence[DefinitionEdge],
|
||||||
|
node_by_id: Mapping[str, DefinitionNode],
|
||||||
|
definitions: Mapping[str, DefinitionNodeType],
|
||||||
|
) -> tuple[
|
||||||
|
dict[str, list[DefinitionEdge]],
|
||||||
|
dict[str, set[str]],
|
||||||
|
list[DefinitionEdge],
|
||||||
|
list[DefinitionDiagnostic],
|
||||||
|
]:
|
||||||
|
incoming: dict[str, list[DefinitionEdge]] = {node_id: [] for node_id in node_by_id}
|
||||||
|
undirected: dict[str, set[str]] = {node_id: set() for node_id in node_by_id}
|
||||||
|
topology_edges: list[DefinitionEdge] = []
|
||||||
|
diagnostics: list[DefinitionDiagnostic] = []
|
||||||
|
for edge in edges:
|
||||||
|
structural_error = _edge_structure_diagnostic(edge, node_by_id)
|
||||||
|
if structural_error is not None:
|
||||||
|
diagnostics.append(structural_error)
|
||||||
|
continue
|
||||||
|
topology_edges.append(edge)
|
||||||
|
port_error = _edge_port_diagnostic(
|
||||||
|
edge,
|
||||||
|
source_definition=definitions.get(node_by_id[edge.source].type),
|
||||||
|
target_definition=definitions.get(node_by_id[edge.target].type),
|
||||||
|
)
|
||||||
|
if port_error is not None:
|
||||||
|
diagnostics.append(port_error)
|
||||||
|
continue
|
||||||
|
incoming[edge.target].append(edge)
|
||||||
|
undirected[edge.source].add(edge.target)
|
||||||
|
undirected[edge.target].add(edge.source)
|
||||||
|
return incoming, undirected, topology_edges, diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _edge_structure_diagnostic(
|
||||||
|
edge: DefinitionEdge,
|
||||||
|
node_by_id: Mapping[str, DefinitionNode],
|
||||||
|
) -> DefinitionDiagnostic | None:
|
||||||
|
if edge.source not in node_by_id:
|
||||||
|
return _error(
|
||||||
|
"edge.unknown_source",
|
||||||
|
f"Edge {edge.id!r} references an unknown source node.",
|
||||||
|
)
|
||||||
|
if edge.target not in node_by_id:
|
||||||
|
return _error(
|
||||||
|
"edge.unknown_target",
|
||||||
|
f"Edge {edge.id!r} references an unknown target node.",
|
||||||
|
)
|
||||||
|
if edge.source == edge.target:
|
||||||
|
return _error(
|
||||||
|
"edge.self_reference",
|
||||||
|
"A node cannot connect to itself.",
|
||||||
|
node_id=edge.source,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _edge_port_diagnostic(
|
||||||
|
edge: DefinitionEdge,
|
||||||
|
*,
|
||||||
|
source_definition: DefinitionNodeType | None,
|
||||||
|
target_definition: DefinitionNodeType | None,
|
||||||
|
) -> DefinitionDiagnostic | None:
|
||||||
|
if source_definition is not None and edge.source_port not in {
|
||||||
|
port.id for port in source_definition.output_ports
|
||||||
|
}:
|
||||||
|
return _error(
|
||||||
|
"edge.unknown_source_port",
|
||||||
|
f"Node {edge.source!r} has no output port {edge.source_port!r}.",
|
||||||
|
node_id=edge.source,
|
||||||
|
)
|
||||||
|
if target_definition is not None and edge.target_port not in {
|
||||||
|
port.id for port in target_definition.input_ports
|
||||||
|
}:
|
||||||
|
return _error(
|
||||||
|
"edge.unknown_target_port",
|
||||||
|
f"Node {edge.target!r} has no input port {edge.target_port!r}.",
|
||||||
|
node_id=edge.target,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _node_port_diagnostics(
|
||||||
|
*,
|
||||||
|
nodes: Sequence[DefinitionNode],
|
||||||
|
definitions: Mapping[str, DefinitionNodeType],
|
||||||
|
incoming: Mapping[str, Sequence[DefinitionEdge]],
|
||||||
|
library_id: str,
|
||||||
|
) -> list[DefinitionDiagnostic]:
|
||||||
|
diagnostics: list[DefinitionDiagnostic] = []
|
||||||
|
for node in nodes:
|
||||||
|
definition = definitions.get(node.type)
|
||||||
|
if definition is None:
|
||||||
|
diagnostics.append(
|
||||||
|
_error(
|
||||||
|
"node.unsupported_type",
|
||||||
|
f"Node type {node.type!r} is not in the {library_id!r} library.",
|
||||||
|
node_id=node.id,
|
||||||
|
field="type",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
for port in definition.input_ports:
|
||||||
|
connections = [
|
||||||
|
edge for edge in incoming.get(node.id, ()) if edge.target_port == port.id
|
||||||
|
]
|
||||||
|
minimum = port.minimum_connections if port.required else 0
|
||||||
|
if len(connections) < minimum:
|
||||||
|
diagnostics.append(
|
||||||
|
_error(
|
||||||
|
"node.input_required",
|
||||||
|
(
|
||||||
|
f"{definition.label} requires {minimum} "
|
||||||
|
f"{port.label.lower()} connection"
|
||||||
|
f"{'' if minimum == 1 else 's'}."
|
||||||
|
),
|
||||||
|
node_id=node.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not port.multiple and len(connections) > 1:
|
||||||
|
diagnostics.append(
|
||||||
|
_error(
|
||||||
|
"node.input_multiple",
|
||||||
|
f"{port.label} accepts only one connection.",
|
||||||
|
node_id=node.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _node_count_diagnostics(
|
||||||
|
*,
|
||||||
|
nodes: Sequence[DefinitionNode],
|
||||||
|
definitions: Mapping[str, DefinitionNodeType],
|
||||||
|
constraints: DefinitionGraphConstraints,
|
||||||
|
) -> list[DefinitionDiagnostic]:
|
||||||
|
diagnostics: list[DefinitionDiagnostic] = []
|
||||||
|
for count_constraint in constraints.node_counts:
|
||||||
|
count = sum(
|
||||||
|
count_constraint.matches(node, definitions.get(node.type))
|
||||||
|
for node in nodes
|
||||||
|
)
|
||||||
|
if count < count_constraint.minimum:
|
||||||
|
diagnostics.append(
|
||||||
|
_error(
|
||||||
|
count_constraint.code,
|
||||||
|
(
|
||||||
|
f"A definition needs at least {count_constraint.minimum} "
|
||||||
|
f"{count_constraint.label} node"
|
||||||
|
f"{'' if count_constraint.minimum == 1 else 's'}."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if count_constraint.maximum is not None and count > count_constraint.maximum:
|
||||||
|
diagnostics.append(
|
||||||
|
_error(
|
||||||
|
count_constraint.code,
|
||||||
|
(
|
||||||
|
f"A definition allows at most {count_constraint.maximum} "
|
||||||
|
f"{count_constraint.label} node"
|
||||||
|
f"{'' if count_constraint.maximum == 1 else 's'}."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _topology_diagnostics(
|
||||||
|
*,
|
||||||
|
nodes: Sequence[DefinitionNode],
|
||||||
|
node_by_id: Mapping[str, DefinitionNode],
|
||||||
|
topology_edges: Sequence[DefinitionEdge],
|
||||||
|
undirected: Mapping[str, set[str]],
|
||||||
|
constraints: DefinitionGraphConstraints,
|
||||||
|
) -> list[DefinitionDiagnostic]:
|
||||||
|
diagnostics: list[DefinitionDiagnostic] = []
|
||||||
|
_, cyclic = definition_topological_order(nodes, topology_edges)
|
||||||
|
if cyclic and not constraints.allow_cycles:
|
||||||
|
diagnostics.append(_error("graph.cycle", "Definition edges must form an acyclic graph."))
|
||||||
|
if constraints.require_connected and len(node_by_id) > 1:
|
||||||
|
connected = _connected_nodes(next(iter(node_by_id)), undirected)
|
||||||
|
if len(connected) != len(node_by_id):
|
||||||
|
diagnostics.append(
|
||||||
|
_error("graph.disconnected", "Every node must belong to one connected definition.")
|
||||||
|
)
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def definition_topological_order(
|
||||||
|
nodes: Sequence[DefinitionNode],
|
||||||
|
edges: Sequence[DefinitionEdge],
|
||||||
|
) -> tuple[tuple[str, ...], bool]:
|
||||||
|
node_ids = [node.id for node in nodes]
|
||||||
|
incoming_count = {node_id: 0 for node_id in node_ids}
|
||||||
|
outgoing: dict[str, list[str]] = {node_id: [] for node_id in node_ids}
|
||||||
|
for edge in edges:
|
||||||
|
if (
|
||||||
|
edge.source in outgoing
|
||||||
|
and edge.target in incoming_count
|
||||||
|
and edge.source != edge.target
|
||||||
|
):
|
||||||
|
outgoing[edge.source].append(edge.target)
|
||||||
|
incoming_count[edge.target] += 1
|
||||||
|
ready = deque(node_id for node_id in node_ids if incoming_count[node_id] == 0)
|
||||||
|
ordered: list[str] = []
|
||||||
|
while ready:
|
||||||
|
node_id = ready.popleft()
|
||||||
|
ordered.append(node_id)
|
||||||
|
for target in outgoing[node_id]:
|
||||||
|
incoming_count[target] -= 1
|
||||||
|
if incoming_count[target] == 0:
|
||||||
|
ready.append(target)
|
||||||
|
return tuple(ordered), len(ordered) != len(node_ids)
|
||||||
|
|
||||||
|
|
||||||
|
def _connected_nodes(start: str, adjacency: Mapping[str, set[str]]) -> set[str]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
pending = [start]
|
||||||
|
while pending:
|
||||||
|
node_id = pending.pop()
|
||||||
|
if node_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(node_id)
|
||||||
|
pending.extend(adjacency.get(node_id, ()))
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def _error(
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
node_id: str | None = None,
|
||||||
|
field: str | None = None,
|
||||||
|
) -> DefinitionDiagnostic:
|
||||||
|
return DefinitionDiagnostic(
|
||||||
|
severity="error",
|
||||||
|
code=code,
|
||||||
|
message=message,
|
||||||
|
node_id=node_id,
|
||||||
|
field=field,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DefinitionConfigField",
|
||||||
|
"DefinitionDiagnostic",
|
||||||
|
"DefinitionEdge",
|
||||||
|
"DefinitionGraphConstraints",
|
||||||
|
"DefinitionGraphLibrary",
|
||||||
|
"DefinitionNode",
|
||||||
|
"DefinitionNodeCountConstraint",
|
||||||
|
"DefinitionNodeType",
|
||||||
|
"DefinitionPort",
|
||||||
|
"definition_topological_order",
|
||||||
|
"validate_definition_graph",
|
||||||
|
]
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_DISTRIBUTION_LIST_SOURCE = "dist_lists.source"
|
||||||
|
CAPABILITY_DISTRIBUTION_LIST_EXPAND = "dist_lists.expand"
|
||||||
|
CAPABILITY_DISTRIBUTION_LIST_WRITER = "dist_lists.writer"
|
||||||
|
CAPABILITY_RECIPIENT_CHANNEL_FACTS = "addresses.channel_facts"
|
||||||
|
CAPABILITY_POLICY_DISTRIBUTION_CHANNELS = "policy.distribution_channels"
|
||||||
|
|
||||||
|
DistributionDefinitionKind = Literal["static", "parameterized", "dynamic", "template"]
|
||||||
|
DistributionEntryMode = Literal["include", "exclude", "override"]
|
||||||
|
DistributionEntryKind = Literal[
|
||||||
|
"address_contact",
|
||||||
|
"address_list",
|
||||||
|
"address_email",
|
||||||
|
"raw_email",
|
||||||
|
"raw_postal_address",
|
||||||
|
"internal_mail",
|
||||||
|
"portal",
|
||||||
|
"idm_identity",
|
||||||
|
"idm_group",
|
||||||
|
"organization_unit",
|
||||||
|
"function",
|
||||||
|
"effective_function_incumbent",
|
||||||
|
"dataflow_result",
|
||||||
|
"distribution_list",
|
||||||
|
]
|
||||||
|
DistributionChannel = Literal["email", "postal", "internal_mail", "portal"]
|
||||||
|
DistributionOutcome = Literal[
|
||||||
|
"usable",
|
||||||
|
"unresolved",
|
||||||
|
"invalid",
|
||||||
|
"suppressed",
|
||||||
|
"ambiguous",
|
||||||
|
"duplicate",
|
||||||
|
"policy_blocked",
|
||||||
|
"provider_unavailable",
|
||||||
|
"stale",
|
||||||
|
]
|
||||||
|
DistributionExplanationSeverity = Literal["info", "warning", "error"]
|
||||||
|
|
||||||
|
|
||||||
|
class DistributionListError(ValueError):
|
||||||
|
"""Stable base error for provider-neutral distribution-list operations."""
|
||||||
|
|
||||||
|
|
||||||
|
class DistributionListNotFoundError(DistributionListError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DistributionListConflictError(DistributionListError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DistributionListUnavailableError(DistributionListError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DistributionSourceReference:
|
||||||
|
provider: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
revision: str | None = None
|
||||||
|
fingerprint: str | None = None
|
||||||
|
label: str | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DistributionExplanation:
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
severity: DistributionExplanationSeverity = "warning"
|
||||||
|
provider: str | None = None
|
||||||
|
source: DistributionSourceReference | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DistributionParameterDefinition:
|
||||||
|
key: str
|
||||||
|
value_type: Literal[
|
||||||
|
"string",
|
||||||
|
"integer",
|
||||||
|
"number",
|
||||||
|
"boolean",
|
||||||
|
"date",
|
||||||
|
"datetime",
|
||||||
|
"string_list",
|
||||||
|
]
|
||||||
|
label: str | None = None
|
||||||
|
required: bool = False
|
||||||
|
default: object | None = None
|
||||||
|
allowed_values: tuple[object, ...] = ()
|
||||||
|
minimum: float | None = None
|
||||||
|
maximum: float | None = None
|
||||||
|
pattern: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DistributionListEntryRef:
|
||||||
|
id: str
|
||||||
|
kind: DistributionEntryKind
|
||||||
|
mode: DistributionEntryMode
|
||||||
|
source: DistributionSourceReference
|
||||||
|
label: str | None = None
|
||||||
|
purpose: str | None = None
|
||||||
|
requested_channels: tuple[DistributionChannel, ...] = ()
|
||||||
|
effective_from: datetime | None = None
|
||||||
|
effective_until: datetime | None = None
|
||||||
|
order: int = 0
|
||||||
|
configuration: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DistributionListSourceRef:
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
name: str
|
||||||
|
revision_id: str
|
||||||
|
revision: int
|
||||||
|
definition_hash: str
|
||||||
|
definition_kind: DistributionDefinitionKind = "static"
|
||||||
|
description: str | None = None
|
||||||
|
status: str = "active"
|
||||||
|
entry_count: int = 0
|
||||||
|
read_only: bool = False
|
||||||
|
stale: bool = False
|
||||||
|
parameters: tuple[DistributionParameterDefinition, ...] = ()
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DistributionExpansionLimits:
|
||||||
|
max_entries: int = 500
|
||||||
|
max_results: int = 5_000
|
||||||
|
max_depth: int = 8
|
||||||
|
max_provider_results: int = 2_000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DistributionExpansionRequest:
|
||||||
|
list_id: str
|
||||||
|
revision: int | None = None
|
||||||
|
effective_at: datetime | None = None
|
||||||
|
purpose: str | None = None
|
||||||
|
requested_channels: tuple[DistributionChannel, ...] = ()
|
||||||
|
parameters: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
preview: bool = False
|
||||||
|
freeze: bool = False
|
||||||
|
idempotency_key: str | None = None
|
||||||
|
limits: DistributionExpansionLimits = field(
|
||||||
|
default_factory=DistributionExpansionLimits
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DistributionChannelCandidate:
|
||||||
|
channel: DistributionChannel
|
||||||
|
target: str
|
||||||
|
target_key: str
|
||||||
|
status: DistributionOutcome = "usable"
|
||||||
|
contact_point_id: str | None = None
|
||||||
|
locale: str | None = None
|
||||||
|
preferred: bool = False
|
||||||
|
reason_code: str | None = None
|
||||||
|
explanation: str | None = None
|
||||||
|
source: DistributionSourceReference | None = None
|
||||||
|
decision_provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DistributionRecipientRef:
|
||||||
|
recipient_key: str
|
||||||
|
display_name: str
|
||||||
|
status: DistributionOutcome
|
||||||
|
channels: tuple[DistributionChannelCandidate, ...] = ()
|
||||||
|
identity_id: str | None = None
|
||||||
|
account_id: str | None = None
|
||||||
|
contact_id: str | None = None
|
||||||
|
organization_unit_id: str | None = None
|
||||||
|
function_id: str | None = None
|
||||||
|
source_entry_ids: tuple[str, ...] = ()
|
||||||
|
explanations: tuple[DistributionExplanation, ...] = ()
|
||||||
|
attributes: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DistributionProviderEvidence:
|
||||||
|
provider: str
|
||||||
|
source: DistributionSourceReference
|
||||||
|
actual_revision: str | None = None
|
||||||
|
actual_fingerprint: str | None = None
|
||||||
|
stale: bool = False
|
||||||
|
generated_at: datetime | None = None
|
||||||
|
details: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DistributionExpansionResult:
|
||||||
|
source: DistributionListSourceRef
|
||||||
|
request: DistributionExpansionRequest
|
||||||
|
recipients: tuple[DistributionRecipientRef, ...]
|
||||||
|
excluded: tuple[DistributionRecipientRef, ...] = ()
|
||||||
|
diagnostics: tuple[DistributionExplanation, ...] = ()
|
||||||
|
provider_evidence: tuple[DistributionProviderEvidence, ...] = ()
|
||||||
|
expansion_hash: str = ""
|
||||||
|
generated_at: datetime | None = None
|
||||||
|
snapshot_id: str | None = None
|
||||||
|
stale: bool = False
|
||||||
|
truncated: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DistributionSnapshotRef:
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
list_id: str
|
||||||
|
revision_id: str
|
||||||
|
revision: int
|
||||||
|
expansion_hash: str
|
||||||
|
generated_at: datetime
|
||||||
|
effective_at: datetime
|
||||||
|
recipient_count: int
|
||||||
|
excluded_count: int
|
||||||
|
stale: bool
|
||||||
|
truncated: bool
|
||||||
|
request: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
recipients: tuple[DistributionRecipientRef, ...] = ()
|
||||||
|
excluded: tuple[DistributionRecipientRef, ...] = ()
|
||||||
|
diagnostics: tuple[DistributionExplanation, ...] = ()
|
||||||
|
provider_evidence: tuple[DistributionProviderEvidence, ...] = ()
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DistributionWriteDecision:
|
||||||
|
list_id: str | None
|
||||||
|
operation: str
|
||||||
|
allowed: bool
|
||||||
|
reason_code: str
|
||||||
|
explanation: str
|
||||||
|
read_only: bool = False
|
||||||
|
required_scopes: tuple[str, ...] = ()
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RecipientChannelFactsRequest:
|
||||||
|
tenant_id: str
|
||||||
|
source: DistributionSourceReference
|
||||||
|
recipient_key: str
|
||||||
|
effective_at: datetime
|
||||||
|
purpose: str | None = None
|
||||||
|
requested_channels: tuple[DistributionChannel, ...] = ()
|
||||||
|
context: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RecipientChannelFacts:
|
||||||
|
candidates: tuple[DistributionChannelCandidate, ...]
|
||||||
|
explanations: tuple[DistributionExplanation, ...] = ()
|
||||||
|
source_revision: str | None = None
|
||||||
|
source_fingerprint: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DistributionChannelPolicyRequest:
|
||||||
|
tenant_id: str
|
||||||
|
list_id: str
|
||||||
|
purpose: str | None
|
||||||
|
effective_at: datetime
|
||||||
|
recipient: DistributionRecipientRef
|
||||||
|
candidate: DistributionChannelCandidate
|
||||||
|
context: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DistributionChannelPolicyDecision:
|
||||||
|
allowed: bool
|
||||||
|
reason_code: str
|
||||||
|
explanation: str
|
||||||
|
source_path: tuple[Mapping[str, object], ...] = ()
|
||||||
|
requirements: tuple[str, ...] = ()
|
||||||
|
details: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DistributionListSourceProvider(Protocol):
|
||||||
|
def list_sources(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: str = "",
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[DistributionListSourceRef]: ...
|
||||||
|
|
||||||
|
def get_source(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
list_id: str,
|
||||||
|
revision: int | None = None,
|
||||||
|
) -> DistributionListSourceRef | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DistributionListExpansionProvider(Protocol):
|
||||||
|
def expand(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: DistributionExpansionRequest,
|
||||||
|
) -> DistributionExpansionResult: ...
|
||||||
|
|
||||||
|
def get_snapshot(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
snapshot_id: str,
|
||||||
|
) -> DistributionSnapshotRef | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DistributionListWriter(Protocol):
|
||||||
|
def explain_write(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
list_id: str | None,
|
||||||
|
operation: str,
|
||||||
|
) -> DistributionWriteDecision: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class RecipientChannelFactsProvider(Protocol):
|
||||||
|
def resolve_channel_facts(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: RecipientChannelFactsRequest,
|
||||||
|
) -> RecipientChannelFacts: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DistributionChannelPolicyProvider(Protocol):
|
||||||
|
def resolve_distribution_channel(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: DistributionChannelPolicyRequest,
|
||||||
|
) -> DistributionChannelPolicyDecision: ...
|
||||||
|
|
||||||
|
|
||||||
|
def distribution_list_source_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> DistributionListSourceProvider | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_DISTRIBUTION_LIST_SOURCE)
|
||||||
|
return capability if isinstance(capability, DistributionListSourceProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def distribution_list_expansion_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> DistributionListExpansionProvider | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_DISTRIBUTION_LIST_EXPAND)
|
||||||
|
return (
|
||||||
|
capability
|
||||||
|
if isinstance(capability, DistributionListExpansionProvider)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _capability(registry: object | None, name: str) -> object | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(name)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return registry.capability(name)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [name for name in globals() if name.startswith("CAPABILITY_") or name.startswith("Distribution") or name.startswith("Recipient") or name.startswith("distribution_")]
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
DSAR_CAPABILITY_PREFIX = "privacy.dsar."
|
||||||
|
|
||||||
|
DsarRequestKind = Literal["access", "erasure", "access_and_erasure"]
|
||||||
|
DsarActionKind = Literal[
|
||||||
|
"delete",
|
||||||
|
"anonymize",
|
||||||
|
"revoke",
|
||||||
|
"detach",
|
||||||
|
"retain",
|
||||||
|
"manual_review",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DsarSubjectRef:
|
||||||
|
account_id: str | None = None
|
||||||
|
identity_id: str | None = None
|
||||||
|
membership_id: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
external_references: Mapping[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def has_selector(self) -> bool:
|
||||||
|
return bool(
|
||||||
|
self.account_id
|
||||||
|
or self.identity_id
|
||||||
|
or self.membership_id
|
||||||
|
or self.email
|
||||||
|
or self.external_references
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"account_id": self.account_id,
|
||||||
|
"identity_id": self.identity_id,
|
||||||
|
"membership_id": self.membership_id,
|
||||||
|
"email": self.email,
|
||||||
|
"external_references": dict(self.external_references),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DsarRecordRef:
|
||||||
|
provider_id: str
|
||||||
|
module_id: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
category: str
|
||||||
|
title: str
|
||||||
|
data: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
observed_at: datetime | None = None
|
||||||
|
immutable_evidence: bool = False
|
||||||
|
retention_reason: str | None = None
|
||||||
|
source_path: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"provider_id": self.provider_id,
|
||||||
|
"module_id": self.module_id,
|
||||||
|
"resource_type": self.resource_type,
|
||||||
|
"resource_id": self.resource_id,
|
||||||
|
"category": self.category,
|
||||||
|
"title": self.title,
|
||||||
|
"data": dict(self.data),
|
||||||
|
"observed_at": self.observed_at.isoformat() if self.observed_at else None,
|
||||||
|
"immutable_evidence": self.immutable_evidence,
|
||||||
|
"retention_reason": self.retention_reason,
|
||||||
|
"source_path": self.source_path,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DsarErasureActionRef:
|
||||||
|
action_id: str
|
||||||
|
provider_id: str
|
||||||
|
module_id: str
|
||||||
|
kind: DsarActionKind
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
title: str
|
||||||
|
rationale: str
|
||||||
|
executable: bool
|
||||||
|
irreversible: bool = False
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"action_id": self.action_id,
|
||||||
|
"provider_id": self.provider_id,
|
||||||
|
"module_id": self.module_id,
|
||||||
|
"kind": self.kind,
|
||||||
|
"resource_type": self.resource_type,
|
||||||
|
"resource_id": self.resource_id,
|
||||||
|
"title": self.title,
|
||||||
|
"rationale": self.rationale,
|
||||||
|
"executable": self.executable,
|
||||||
|
"irreversible": self.irreversible,
|
||||||
|
"metadata": dict(self.metadata),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DsarExecutionResultRef:
|
||||||
|
action_id: str
|
||||||
|
status: Literal["executed", "unchanged", "failed", "blocked"]
|
||||||
|
summary: str
|
||||||
|
evidence: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"action_id": self.action_id,
|
||||||
|
"status": self.status,
|
||||||
|
"summary": self.summary,
|
||||||
|
"evidence": dict(self.evidence),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DsarProvider(Protocol):
|
||||||
|
provider_id: str
|
||||||
|
module_id: str
|
||||||
|
|
||||||
|
def search_subject(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
) -> Sequence[DsarRecordRef]: ...
|
||||||
|
|
||||||
|
def plan_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
records: Sequence[DsarRecordRef],
|
||||||
|
) -> Sequence[DsarErasureActionRef]: ...
|
||||||
|
|
||||||
|
def execute_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
actions: Sequence[DsarErasureActionRef],
|
||||||
|
request_id: str,
|
||||||
|
) -> Sequence[DsarExecutionResultRef]: ...
|
||||||
|
|
||||||
|
|
||||||
|
def dsar_capability_name(module_id: str) -> str:
|
||||||
|
normalized = module_id.strip().casefold()
|
||||||
|
if not normalized or not normalized.replace("_", "").isalnum():
|
||||||
|
raise ValueError("DSAR module id must be an identifier.")
|
||||||
|
return f"{DSAR_CAPABILITY_PREFIX}{normalized}"
|
||||||
|
|
||||||
|
|
||||||
|
def dsar_provider_names(registry: object | None) -> tuple[str, ...]:
|
||||||
|
if registry is None or not hasattr(registry, "capability_names"):
|
||||||
|
return ()
|
||||||
|
return tuple(
|
||||||
|
name
|
||||||
|
for name in registry.capability_names()
|
||||||
|
if name.startswith(DSAR_CAPABILITY_PREFIX)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dsar_provider(
|
||||||
|
registry: object,
|
||||||
|
capability_name: str,
|
||||||
|
) -> DsarProvider:
|
||||||
|
provider = registry.require_capability(capability_name)
|
||||||
|
if not isinstance(provider, DsarProvider):
|
||||||
|
raise TypeError(f"{capability_name} does not implement DsarProvider")
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DSAR_CAPABILITY_PREFIX",
|
||||||
|
"DsarActionKind",
|
||||||
|
"DsarErasureActionRef",
|
||||||
|
"DsarExecutionResultRef",
|
||||||
|
"DsarProvider",
|
||||||
|
"DsarRecordRef",
|
||||||
|
"DsarRequestKind",
|
||||||
|
"DsarSubjectRef",
|
||||||
|
"dsar_capability_name",
|
||||||
|
"dsar_provider",
|
||||||
|
"dsar_provider_names",
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,25 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import re
|
import re
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal, Protocol, runtime_checkable
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import event as sqlalchemy_event
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.institutional import GovernedContextEnvelope
|
||||||
|
|
||||||
|
|
||||||
_TRACE_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
|
_TRACE_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
|
||||||
|
_CONSUMER_ID_RE = re.compile(r"^[a-z][a-z0-9_.:-]{0,127}$")
|
||||||
|
_PENDING_EVENTS_KEY = "govoplan.pending_platform_events"
|
||||||
|
CAPABILITY_PLATFORM_EVENT_OUTBOX = "platform.eventOutbox"
|
||||||
|
|
||||||
|
|
||||||
def new_event_id() -> str:
|
def new_event_id() -> str:
|
||||||
@@ -81,6 +89,7 @@ class PlatformEvent:
|
|||||||
subject: EventObjectRef | None = None
|
subject: EventObjectRef | None = None
|
||||||
resource: EventObjectRef | None = None
|
resource: EventObjectRef | None = None
|
||||||
classification: EventClassification = "internal"
|
classification: EventClassification = "internal"
|
||||||
|
institutional_context: GovernedContextEnvelope | None = None
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -96,10 +105,118 @@ class PlatformEvent:
|
|||||||
"subject": self.subject.to_dict() if self.subject else None,
|
"subject": self.subject.to_dict() if self.subject else None,
|
||||||
"resource": self.resource.to_dict() if self.resource else None,
|
"resource": self.resource.to_dict() if self.resource else None,
|
||||||
"classification": self.classification,
|
"classification": self.classification,
|
||||||
|
"institutional_context": (
|
||||||
|
self.institutional_context.to_dict()
|
||||||
|
if self.institutional_context is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
EventHandler = Callable[[PlatformEvent], None]
|
EventHandler = Callable[[PlatformEvent], None]
|
||||||
|
DurableEventHandler = Callable[[PlatformEvent, str], None]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DurableEventConsumer:
|
||||||
|
"""Allowlisted durable consumer with an explicit disclosure boundary."""
|
||||||
|
|
||||||
|
consumer_id: str
|
||||||
|
handler: DurableEventHandler
|
||||||
|
event_types: frozenset[str] = field(
|
||||||
|
default_factory=lambda: frozenset({"*"})
|
||||||
|
)
|
||||||
|
classifications: frozenset[EventClassification] = field(
|
||||||
|
default_factory=lambda: frozenset({"public", "internal"})
|
||||||
|
)
|
||||||
|
policy_decision_ref: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not _CONSUMER_ID_RE.fullmatch(self.consumer_id):
|
||||||
|
raise ValueError("Durable event consumer id is invalid")
|
||||||
|
if not self.event_types or any(
|
||||||
|
item != "*" and not _TRACE_ID_RE.fullmatch(item)
|
||||||
|
for item in self.event_types
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Durable event consumers require valid event-type allowlists"
|
||||||
|
)
|
||||||
|
invalid_classifications = set(self.classifications) - {
|
||||||
|
"public",
|
||||||
|
"internal",
|
||||||
|
"confidential",
|
||||||
|
"restricted",
|
||||||
|
}
|
||||||
|
if not self.classifications or invalid_classifications:
|
||||||
|
raise ValueError(
|
||||||
|
"Durable event consumer classifications are invalid"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
self.classifications & {"confidential", "restricted"}
|
||||||
|
and not normalize_trace_id(self.policy_decision_ref)
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Confidential or restricted event subscriptions require "
|
||||||
|
"an explicit policy decision reference"
|
||||||
|
)
|
||||||
|
|
||||||
|
def accepts(self, event: PlatformEvent) -> bool:
|
||||||
|
return (
|
||||||
|
event.classification in self.classifications
|
||||||
|
and (
|
||||||
|
"*" in self.event_types
|
||||||
|
or event.type in self.event_types
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def delivery_key(self, event: PlatformEvent) -> str:
|
||||||
|
return f"{event.event_id}:{self.consumer_id}"
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PlatformEventOutbox(Protocol):
|
||||||
|
def enqueue(self, session: object, event: PlatformEvent) -> object:
|
||||||
|
...
|
||||||
|
|
||||||
|
def dispatch_pending(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
tenantless_only: bool = False,
|
||||||
|
consumers: Sequence[DurableEventConsumer] = (),
|
||||||
|
observer: EventHandler | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Mapping[str, int]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def replay_delivery(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
event_id: str,
|
||||||
|
consumer_id: str,
|
||||||
|
operator_id: str,
|
||||||
|
reason: str,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def purge_terminal(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
tenantless_only: bool = False,
|
||||||
|
before: datetime,
|
||||||
|
limit: int = 500,
|
||||||
|
) -> Mapping[str, int]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def delivery_metrics(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
def current_event_trace() -> EventTrace | None:
|
def current_event_trace() -> EventTrace | None:
|
||||||
@@ -149,6 +266,7 @@ def ensure_event_trace(event: PlatformEvent) -> PlatformEvent:
|
|||||||
subject=event.subject,
|
subject=event.subject,
|
||||||
resource=event.resource,
|
resource=event.resource,
|
||||||
classification=event.classification,
|
classification=event.classification,
|
||||||
|
institutional_context=event.institutional_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -189,5 +307,85 @@ def publish_platform_event(event: PlatformEvent) -> None:
|
|||||||
platform_event_bus().publish(event)
|
platform_event_bus().publish(event)
|
||||||
|
|
||||||
|
|
||||||
|
def platform_event_outbox(
|
||||||
|
registry: object | None = None,
|
||||||
|
) -> PlatformEventOutbox | None:
|
||||||
|
if registry is None:
|
||||||
|
from govoplan_core.core.runtime import get_registry
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
|
||||||
|
return capability if isinstance(capability, PlatformEventOutbox) else None
|
||||||
|
|
||||||
|
|
||||||
|
def emit_platform_event(
|
||||||
|
session: Session,
|
||||||
|
event: PlatformEvent,
|
||||||
|
*,
|
||||||
|
registry: object | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Persist an event with its transaction or publish it after commit.
|
||||||
|
|
||||||
|
The durable outbox is optional so reduced module combinations remain
|
||||||
|
usable. Without it, the event is kept on the SQLAlchemy session and only
|
||||||
|
delivered to the process-local bus after the outer transaction commits.
|
||||||
|
"""
|
||||||
|
|
||||||
|
traced = ensure_event_trace(event)
|
||||||
|
outbox = platform_event_outbox(registry)
|
||||||
|
if outbox is not None:
|
||||||
|
outbox.enqueue(session, traced)
|
||||||
|
return
|
||||||
|
transaction = (
|
||||||
|
session.get_nested_transaction()
|
||||||
|
or session.get_transaction()
|
||||||
|
or session.begin()
|
||||||
|
)
|
||||||
|
pending_by_transaction = session.info.setdefault(
|
||||||
|
_PENDING_EVENTS_KEY,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
pending_by_transaction.setdefault(transaction, []).append(
|
||||||
|
(platform_event_bus(), traced)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@sqlalchemy_event.listens_for(Session, "after_commit")
|
||||||
|
def _publish_committed_events(session: Session) -> None:
|
||||||
|
transaction = session.get_nested_transaction() or session.get_transaction()
|
||||||
|
if transaction is None:
|
||||||
|
return
|
||||||
|
pending_by_transaction = session.info.get(_PENDING_EVENTS_KEY)
|
||||||
|
if not isinstance(pending_by_transaction, dict):
|
||||||
|
return
|
||||||
|
pending = pending_by_transaction.pop(transaction, ())
|
||||||
|
parent = transaction.parent
|
||||||
|
if parent is not None:
|
||||||
|
pending_by_transaction.setdefault(parent, []).extend(pending)
|
||||||
|
else:
|
||||||
|
for bus, event in pending:
|
||||||
|
bus.publish(event)
|
||||||
|
if not pending_by_transaction:
|
||||||
|
session.info.pop(_PENDING_EVENTS_KEY, None)
|
||||||
|
|
||||||
|
|
||||||
|
@sqlalchemy_event.listens_for(Session, "after_rollback")
|
||||||
|
def _discard_rolled_back_events(session: Session) -> None:
|
||||||
|
transaction = session.get_nested_transaction() or session.get_transaction()
|
||||||
|
pending_by_transaction = session.info.get(_PENDING_EVENTS_KEY)
|
||||||
|
if transaction is None or not isinstance(pending_by_transaction, dict):
|
||||||
|
return
|
||||||
|
pending_by_transaction.pop(transaction, None)
|
||||||
|
if transaction.parent is None or not pending_by_transaction:
|
||||||
|
session.info.pop(_PENDING_EVENTS_KEY, None)
|
||||||
|
|
||||||
|
|
||||||
def _compact_dict(value: Mapping[str, Any]) -> dict[str, Any]:
|
def _compact_dict(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
return {key: item for key, item in value.items() if item is not None}
|
return {key: item for key, item in value.items() if item is not None}
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
IntegrationMaturity = Literal[
|
||||||
|
"discover",
|
||||||
|
"link",
|
||||||
|
"search",
|
||||||
|
"read",
|
||||||
|
"publish",
|
||||||
|
"synchronize",
|
||||||
|
"migrate",
|
||||||
|
"replace",
|
||||||
|
]
|
||||||
|
SourceAuthorityMode = Literal[
|
||||||
|
"native_authoritative",
|
||||||
|
"external_authoritative",
|
||||||
|
"external_mirror",
|
||||||
|
"governed_sync",
|
||||||
|
"governance_overlay",
|
||||||
|
"linked_reference",
|
||||||
|
]
|
||||||
|
|
||||||
|
INTEGRATION_MATURITY_ORDER: tuple[IntegrationMaturity, ...] = (
|
||||||
|
"discover",
|
||||||
|
"link",
|
||||||
|
"search",
|
||||||
|
"read",
|
||||||
|
"publish",
|
||||||
|
"synchronize",
|
||||||
|
"migrate",
|
||||||
|
"replace",
|
||||||
|
)
|
||||||
|
SOURCE_AUTHORITY_MODES: tuple[SourceAuthorityMode, ...] = (
|
||||||
|
"native_authoritative",
|
||||||
|
"external_authoritative",
|
||||||
|
"external_mirror",
|
||||||
|
"governed_sync",
|
||||||
|
"governance_overlay",
|
||||||
|
"linked_reference",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalReferenceValidationError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ExternalObjectReference:
|
||||||
|
"""Stable identity and provenance for an object owned by another system."""
|
||||||
|
|
||||||
|
system: str
|
||||||
|
object_type: str
|
||||||
|
object_id: str
|
||||||
|
maturity: IntegrationMaturity = "link"
|
||||||
|
authority_mode: SourceAuthorityMode = "linked_reference"
|
||||||
|
connector_id: str | None = None
|
||||||
|
canonical_url: str | None = None
|
||||||
|
version: str | None = None
|
||||||
|
etag: str | None = None
|
||||||
|
observed_at: datetime | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for field_name in ("system", "object_type", "object_id"):
|
||||||
|
value = str(getattr(self, field_name) or "").strip()
|
||||||
|
if not value:
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
f"External reference {field_name} is required."
|
||||||
|
)
|
||||||
|
if len(value) > 255:
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
f"External reference {field_name} is limited to 255 characters."
|
||||||
|
)
|
||||||
|
object.__setattr__(self, field_name, value)
|
||||||
|
if self.maturity not in INTEGRATION_MATURITY_ORDER:
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
f"Unsupported integration maturity: {self.maturity!r}."
|
||||||
|
)
|
||||||
|
if self.authority_mode not in SOURCE_AUTHORITY_MODES:
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
f"Unsupported source-authority mode: {self.authority_mode!r}."
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
self.authority_mode == "external_mirror"
|
||||||
|
and not self.supports("read")
|
||||||
|
):
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
"External-mirror references require read maturity or higher."
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
self.authority_mode == "governed_sync"
|
||||||
|
and not self.supports("synchronize")
|
||||||
|
):
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
"Governed-sync references require synchronize maturity or higher."
|
||||||
|
)
|
||||||
|
if self.connector_id is not None:
|
||||||
|
connector_id = self.connector_id.strip()
|
||||||
|
if not connector_id:
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
"External reference connector_id cannot be blank."
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "connector_id", connector_id)
|
||||||
|
if self.canonical_url is not None:
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"canonical_url",
|
||||||
|
_validated_reference_url(self.canonical_url),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identity_key(self) -> str:
|
||||||
|
return f"{self.system}:{self.object_type}:{self.object_id}"
|
||||||
|
|
||||||
|
def supports(self, maturity: IntegrationMaturity) -> bool:
|
||||||
|
return integration_maturity_rank(self.maturity) >= integration_maturity_rank(
|
||||||
|
maturity
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"system": self.system,
|
||||||
|
"object_type": self.object_type,
|
||||||
|
"object_id": self.object_id,
|
||||||
|
"maturity": self.maturity,
|
||||||
|
"authority_mode": self.authority_mode,
|
||||||
|
"connector_id": self.connector_id,
|
||||||
|
"canonical_url": self.canonical_url,
|
||||||
|
"version": self.version,
|
||||||
|
"etag": self.etag,
|
||||||
|
"observed_at": (
|
||||||
|
self.observed_at.isoformat() if self.observed_at is not None else None
|
||||||
|
),
|
||||||
|
"metadata": dict(self.metadata),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def integration_maturity_rank(maturity: IntegrationMaturity) -> int:
|
||||||
|
try:
|
||||||
|
return INTEGRATION_MATURITY_ORDER.index(maturity)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
f"Unsupported integration maturity: {maturity!r}."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_reference_url(value: str) -> str:
|
||||||
|
normalized = value.strip()
|
||||||
|
parsed = urlsplit(normalized)
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
"External reference URLs must use HTTP or HTTPS."
|
||||||
|
)
|
||||||
|
if parsed.username is not None or parsed.password is not None:
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
"External reference URLs must not contain credentials."
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ExternalObjectReference",
|
||||||
|
"ExternalReferenceValidationError",
|
||||||
|
"INTEGRATION_MATURITY_ORDER",
|
||||||
|
"IntegrationMaturity",
|
||||||
|
"SOURCE_AUTHORITY_MODES",
|
||||||
|
"SourceAuthorityMode",
|
||||||
|
"integration_maturity_rank",
|
||||||
|
]
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_CONNECTORS_FEEDS = "connectors.feeds"
|
||||||
|
FeedFormat = Literal["rss", "atom"]
|
||||||
|
FeedVisibility = Literal["public", "tenant", "private"]
|
||||||
|
|
||||||
|
|
||||||
|
class FeedCapabilityError(ValueError):
|
||||||
|
"""Stable error raised by feed transport implementations."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FeedEntry:
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
url: str | None = None
|
||||||
|
summary: str | None = None
|
||||||
|
content: str | None = None
|
||||||
|
author: str | None = None
|
||||||
|
published_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
categories: tuple[str, ...] = ()
|
||||||
|
enclosures: tuple[Mapping[str, object], ...] = ()
|
||||||
|
visibility: FeedVisibility = "public"
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FeedDocument:
|
||||||
|
format: FeedFormat
|
||||||
|
title: str
|
||||||
|
source_url: str
|
||||||
|
entries: tuple[FeedEntry, ...]
|
||||||
|
description: str | None = None
|
||||||
|
home_url: str | None = None
|
||||||
|
language: str | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
acquired_at: datetime | None = None
|
||||||
|
fresh_until: datetime | None = None
|
||||||
|
etag: str | None = None
|
||||||
|
last_modified: str | None = None
|
||||||
|
content_type: str | None = None
|
||||||
|
sha256: str = ""
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FeedRenderRequest:
|
||||||
|
format: FeedFormat
|
||||||
|
title: str
|
||||||
|
feed_url: str
|
||||||
|
home_url: str
|
||||||
|
entries: tuple[FeedEntry, ...]
|
||||||
|
description: str | None = None
|
||||||
|
language: str | None = None
|
||||||
|
allowed_visibilities: frozenset[FeedVisibility] = frozenset({"public"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FeedRenderResult:
|
||||||
|
format: FeedFormat
|
||||||
|
content_type: str
|
||||||
|
body: bytes
|
||||||
|
included_entries: int
|
||||||
|
excluded_entries: int
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class FeedProvider(Protocol):
|
||||||
|
def fetch(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
timeout: float = 15,
|
||||||
|
max_entries: int = 2_000,
|
||||||
|
) -> FeedDocument:
|
||||||
|
...
|
||||||
|
|
||||||
|
def parse(
|
||||||
|
self,
|
||||||
|
content: bytes,
|
||||||
|
*,
|
||||||
|
source_url: str,
|
||||||
|
content_type: str | None = None,
|
||||||
|
max_entries: int = 2_000,
|
||||||
|
) -> FeedDocument:
|
||||||
|
...
|
||||||
|
|
||||||
|
def render(self, request: FeedRenderRequest) -> FeedRenderResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def feed_provider(registry: object | None) -> FeedProvider | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_CONNECTORS_FEEDS)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
provider = registry.capability(CAPABILITY_CONNECTORS_FEEDS)
|
||||||
|
return provider if isinstance(provider, FeedProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_CONNECTORS_FEEDS",
|
||||||
|
"FeedCapabilityError",
|
||||||
|
"FeedDocument",
|
||||||
|
"FeedEntry",
|
||||||
|
"FeedFormat",
|
||||||
|
"FeedProvider",
|
||||||
|
"FeedRenderRequest",
|
||||||
|
"FeedRenderResult",
|
||||||
|
"FeedVisibility",
|
||||||
|
"feed_provider",
|
||||||
|
]
|
||||||
@@ -1,13 +1,228 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
from typing import Protocol, runtime_checkable
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
from govoplan_core.core.access import ResourceAccessExplanationProvider
|
from govoplan_core.core.access import ResourceAccessExplanationProvider
|
||||||
|
|
||||||
|
|
||||||
CAPABILITY_FILES_ACCESS = "files.access"
|
CAPABILITY_FILES_ACCESS = "files.access"
|
||||||
|
CAPABILITY_FILES_ARTIFACT_STORE = "files.artifact_store"
|
||||||
|
CAPABILITY_FILES_POSTBOX_REFERENCES = "files.postbox_references"
|
||||||
|
CAPABILITY_FILES_TABULAR_CONTENT = "files.tabular_content"
|
||||||
|
|
||||||
|
|
||||||
|
class ManagedTabularFileError(ValueError):
|
||||||
|
"""Stable base error for exact-version managed tabular file access."""
|
||||||
|
|
||||||
|
|
||||||
|
class ManagedTabularFileNotFoundError(ManagedTabularFileError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ManagedTabularFileAccessError(ManagedTabularFileError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ManagedTabularFileUnavailableError(ManagedTabularFileError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ManagedTabularFileValidationError(ManagedTabularFileError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ManagedTabularFile:
|
||||||
|
"""Authorized metadata for one immutable managed file version."""
|
||||||
|
|
||||||
|
file_asset_id: str
|
||||||
|
file_version_id: str
|
||||||
|
filename: str
|
||||||
|
display_path: str
|
||||||
|
content_type: str | None
|
||||||
|
size_bytes: int
|
||||||
|
sha256: str
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
current_version: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ManagedTabularFileContent:
|
||||||
|
file: ManagedTabularFile
|
||||||
|
payload: bytes
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ManagedArtifactWriteRequest:
|
||||||
|
filename: str
|
||||||
|
payload: bytes
|
||||||
|
content_type: str
|
||||||
|
folder: str = "Generated"
|
||||||
|
description: str | None = None
|
||||||
|
idempotency_key: str | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ManagedArtifactRef:
|
||||||
|
file_asset_id: str
|
||||||
|
file_version_id: str
|
||||||
|
filename: str
|
||||||
|
display_path: str
|
||||||
|
content_type: str
|
||||||
|
size_bytes: int
|
||||||
|
sha256: str
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PostboxFileReferenceRequest:
|
||||||
|
reference_type: str
|
||||||
|
reference_id: str
|
||||||
|
postbox_id: str
|
||||||
|
message_id: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PostboxFileReferenceRef:
|
||||||
|
reference_type: str
|
||||||
|
reference_id: str
|
||||||
|
available: bool
|
||||||
|
reason_code: str
|
||||||
|
file_asset_id: str | None = None
|
||||||
|
file_version_id: str | None = None
|
||||||
|
filename: str | None = None
|
||||||
|
content_type: str | None = None
|
||||||
|
size_bytes: int | None = None
|
||||||
|
sha256: str | None = None
|
||||||
|
download_path: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class FileAccessProvider(ResourceAccessExplanationProvider, Protocol):
|
class FileAccessProvider(ResourceAccessExplanationProvider, Protocol):
|
||||||
"""Resource-level access explanation provider for Files-owned resources."""
|
"""Resource-level access explanation provider for Files-owned resources."""
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ManagedArtifactStore(Protocol):
|
||||||
|
"""Store generated module artifacts without exposing Files internals."""
|
||||||
|
|
||||||
|
def store_artifact(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ManagedArtifactWriteRequest,
|
||||||
|
) -> ManagedArtifactRef: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PostboxFileReferenceProvider(Protocol):
|
||||||
|
"""Resolve Files-owned references after Postbox and Files authorization."""
|
||||||
|
|
||||||
|
def resolve_postbox_references(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
requests: tuple[PostboxFileReferenceRequest, ...],
|
||||||
|
) -> tuple[PostboxFileReferenceRef, ...]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ManagedTabularFileProvider(Protocol):
|
||||||
|
"""List and open authorized CSV/XLSX content without exposing Files internals."""
|
||||||
|
|
||||||
|
def list_tabular_files(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: str = "",
|
||||||
|
limit: int = 100,
|
||||||
|
) -> tuple[ManagedTabularFile, ...]: ...
|
||||||
|
|
||||||
|
def get_tabular_file(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
file_asset_id: str,
|
||||||
|
file_version_id: str | None = None,
|
||||||
|
) -> ManagedTabularFile | None: ...
|
||||||
|
|
||||||
|
def read_tabular_file(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
file_asset_id: str,
|
||||||
|
file_version_id: str,
|
||||||
|
max_bytes: int,
|
||||||
|
) -> ManagedTabularFileContent: ...
|
||||||
|
|
||||||
|
|
||||||
|
def postbox_file_reference_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> PostboxFileReferenceProvider | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_FILES_POSTBOX_REFERENCES)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
provider = registry.require_capability(CAPABILITY_FILES_POSTBOX_REFERENCES)
|
||||||
|
if not isinstance(provider, PostboxFileReferenceProvider):
|
||||||
|
raise TypeError(
|
||||||
|
"files.postbox_references provider does not implement "
|
||||||
|
"PostboxFileReferenceProvider"
|
||||||
|
)
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
def managed_tabular_file_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> ManagedTabularFileProvider | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_FILES_TABULAR_CONTENT)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
provider = registry.require_capability(CAPABILITY_FILES_TABULAR_CONTENT)
|
||||||
|
if not isinstance(provider, ManagedTabularFileProvider):
|
||||||
|
raise TypeError(
|
||||||
|
"files.tabular_content provider does not implement "
|
||||||
|
"ManagedTabularFileProvider"
|
||||||
|
)
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_FILES_ACCESS",
|
||||||
|
"CAPABILITY_FILES_ARTIFACT_STORE",
|
||||||
|
"CAPABILITY_FILES_POSTBOX_REFERENCES",
|
||||||
|
"CAPABILITY_FILES_TABULAR_CONTENT",
|
||||||
|
"FileAccessProvider",
|
||||||
|
"ManagedArtifactRef",
|
||||||
|
"ManagedArtifactStore",
|
||||||
|
"ManagedArtifactWriteRequest",
|
||||||
|
"ManagedTabularFile",
|
||||||
|
"ManagedTabularFileAccessError",
|
||||||
|
"ManagedTabularFileContent",
|
||||||
|
"ManagedTabularFileError",
|
||||||
|
"ManagedTabularFileNotFoundError",
|
||||||
|
"ManagedTabularFileProvider",
|
||||||
|
"ManagedTabularFileUnavailableError",
|
||||||
|
"ManagedTabularFileValidationError",
|
||||||
|
"PostboxFileReferenceProvider",
|
||||||
|
"PostboxFileReferenceRef",
|
||||||
|
"PostboxFileReferenceRequest",
|
||||||
|
"managed_tabular_file_provider",
|
||||||
|
"postbox_file_reference_provider",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,532 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from enum import StrEnum
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, UniqueConstraint, select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||||
|
|
||||||
|
from govoplan_core.audit.logging import audit_event
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
FirstAdminProvisioner,
|
||||||
|
FirstAdminProvisioningError,
|
||||||
|
FirstSystemAdministratorRef,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base, TimestampMixin, utcnow
|
||||||
|
from govoplan_core.tenancy.scope import Tenant
|
||||||
|
|
||||||
|
|
||||||
|
_TENANT_SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||||
|
|
||||||
|
|
||||||
|
class FirstAdminEnrollmentState(StrEnum):
|
||||||
|
INACTIVE = "inactive"
|
||||||
|
ACTIVE = "active"
|
||||||
|
CONSUMED = "consumed"
|
||||||
|
REVOKED = "revoked"
|
||||||
|
|
||||||
|
|
||||||
|
class FirstAdminEnrollmentError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FirstAdminEnrollmentUnavailable(FirstAdminEnrollmentError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FirstAdminEnrollmentCredentialError(FirstAdminEnrollmentError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FirstAdminEnrollmentConflict(FirstAdminEnrollmentError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FirstAdminEnrollment(Base, TimestampMixin):
|
||||||
|
__tablename__ = "core_first_admin_enrollments"
|
||||||
|
|
||||||
|
installation_id: Mapped[str] = mapped_column(String(100), primary_key=True)
|
||||||
|
state: Mapped[str] = mapped_column(
|
||||||
|
String(24),
|
||||||
|
default=FirstAdminEnrollmentState.INACTIVE.value,
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
generation: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
token_sha256: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
token_fingerprint: Mapped[str | None] = mapped_column(String(16))
|
||||||
|
issued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
consumed_account_id: Mapped[str | None] = mapped_column(String(36))
|
||||||
|
consumed_membership_id: Mapped[str | None] = mapped_column(String(36))
|
||||||
|
consumed_tenant_id: Mapped[str | None] = mapped_column(String(36))
|
||||||
|
consumed_email: Mapped[str | None] = mapped_column(String(320))
|
||||||
|
consumed_display_name: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
consumed_request_sha256: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
issue_reason: Mapped[str | None] = mapped_column(String(500))
|
||||||
|
event_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
evidence_head_sha256: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
|
||||||
|
|
||||||
|
class FirstAdminEnrollmentEvent(Base):
|
||||||
|
__tablename__ = "core_first_admin_enrollment_events"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"installation_id",
|
||||||
|
"sequence",
|
||||||
|
name="uq_core_first_admin_enrollment_event_sequence",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
primary_key=True,
|
||||||
|
default=lambda: str(uuid4()),
|
||||||
|
)
|
||||||
|
installation_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey(
|
||||||
|
"core_first_admin_enrollments.installation_id",
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
event_type: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||||
|
generation: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
previous_sha256: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
event_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=utcnow,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IssuedFirstAdminCredential:
|
||||||
|
secret: str
|
||||||
|
fingerprint: str
|
||||||
|
generation: int
|
||||||
|
expires_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FirstAdminEnrollmentStatus:
|
||||||
|
enrollment_required: bool
|
||||||
|
credential_active: bool
|
||||||
|
state: str
|
||||||
|
generation: int
|
||||||
|
expires_at: datetime | None
|
||||||
|
completed_account_id: str | None
|
||||||
|
readiness: dict[str, bool]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FirstAdminEnrollmentResult:
|
||||||
|
administrator: FirstSystemAdministratorRef
|
||||||
|
replayed: bool
|
||||||
|
|
||||||
|
|
||||||
|
def issue_first_admin_credential(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
installation_id: str,
|
||||||
|
provisioner: FirstAdminProvisioner,
|
||||||
|
ttl_seconds: int,
|
||||||
|
reason: str,
|
||||||
|
replace_active: bool = False,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> IssuedFirstAdminCredential:
|
||||||
|
current_time = _utc(now)
|
||||||
|
if ttl_seconds < 60 or ttl_seconds > 24 * 60 * 60:
|
||||||
|
raise ValueError("First-admin enrollment expiry must be between 60 seconds and 24 hours.")
|
||||||
|
if provisioner.has_durable_system_administrator(session):
|
||||||
|
raise FirstAdminEnrollmentUnavailable(
|
||||||
|
"A durable system administrator already exists. Bootstrap enrollment is disabled."
|
||||||
|
)
|
||||||
|
|
||||||
|
enrollment = _locked_enrollment(session, installation_id)
|
||||||
|
if (
|
||||||
|
enrollment.state == FirstAdminEnrollmentState.ACTIVE.value
|
||||||
|
and _is_future(enrollment.expires_at, current_time)
|
||||||
|
and not replace_active
|
||||||
|
):
|
||||||
|
raise FirstAdminEnrollmentConflict(
|
||||||
|
"An unexpired first-admin credential already exists. Use the recovery command to rotate it."
|
||||||
|
)
|
||||||
|
|
||||||
|
secret = secrets.token_urlsafe(48)
|
||||||
|
token_sha256 = _secret_sha256(secret)
|
||||||
|
fingerprint = token_sha256[:12]
|
||||||
|
expires_at = current_time + timedelta(seconds=ttl_seconds)
|
||||||
|
generation = enrollment.generation + 1
|
||||||
|
if enrollment.state == FirstAdminEnrollmentState.ACTIVE.value:
|
||||||
|
_append_event(
|
||||||
|
session,
|
||||||
|
enrollment,
|
||||||
|
event_type="credential_revoked",
|
||||||
|
generation=enrollment.generation,
|
||||||
|
created_at=current_time,
|
||||||
|
evidence={"reason": "local_operator_recovery"},
|
||||||
|
)
|
||||||
|
enrollment.state = FirstAdminEnrollmentState.ACTIVE.value
|
||||||
|
enrollment.generation = generation
|
||||||
|
enrollment.token_sha256 = token_sha256
|
||||||
|
enrollment.token_fingerprint = fingerprint
|
||||||
|
enrollment.issued_at = current_time
|
||||||
|
enrollment.expires_at = expires_at
|
||||||
|
enrollment.consumed_at = None
|
||||||
|
enrollment.consumed_account_id = None
|
||||||
|
enrollment.consumed_membership_id = None
|
||||||
|
enrollment.consumed_tenant_id = None
|
||||||
|
enrollment.consumed_email = None
|
||||||
|
enrollment.consumed_display_name = None
|
||||||
|
enrollment.consumed_request_sha256 = None
|
||||||
|
enrollment.issue_reason = _bounded_reason(reason)
|
||||||
|
session.add(enrollment)
|
||||||
|
_append_event(
|
||||||
|
session,
|
||||||
|
enrollment,
|
||||||
|
event_type="credential_issued",
|
||||||
|
generation=generation,
|
||||||
|
created_at=current_time,
|
||||||
|
evidence={
|
||||||
|
"fingerprint": fingerprint,
|
||||||
|
"expires_at": expires_at.isoformat(),
|
||||||
|
"reason": enrollment.issue_reason,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
audit_event(
|
||||||
|
session,
|
||||||
|
tenant_id=None,
|
||||||
|
scope="system",
|
||||||
|
action="access.first_admin_enrollment.issued",
|
||||||
|
object_type="first_admin_enrollment",
|
||||||
|
object_id=installation_id,
|
||||||
|
details={
|
||||||
|
"generation": generation,
|
||||||
|
"fingerprint": fingerprint,
|
||||||
|
"expires_at": expires_at.isoformat(),
|
||||||
|
"reason": enrollment.issue_reason,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return IssuedFirstAdminCredential(
|
||||||
|
secret=secret,
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
generation=generation,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def first_admin_enrollment_status(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
installation_id: str,
|
||||||
|
provisioner: FirstAdminProvisioner,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> FirstAdminEnrollmentStatus:
|
||||||
|
current_time = _utc(now)
|
||||||
|
administrator_exists = provisioner.has_durable_system_administrator(session)
|
||||||
|
enrollment = session.get(FirstAdminEnrollment, installation_id)
|
||||||
|
state = enrollment.state if enrollment is not None else FirstAdminEnrollmentState.INACTIVE.value
|
||||||
|
active = bool(
|
||||||
|
not administrator_exists
|
||||||
|
and enrollment is not None
|
||||||
|
and state == FirstAdminEnrollmentState.ACTIVE.value
|
||||||
|
and enrollment.token_sha256
|
||||||
|
and _is_future(enrollment.expires_at, current_time)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not administrator_exists
|
||||||
|
and enrollment is not None
|
||||||
|
and state == FirstAdminEnrollmentState.ACTIVE.value
|
||||||
|
and not active
|
||||||
|
):
|
||||||
|
state = "expired"
|
||||||
|
return FirstAdminEnrollmentStatus(
|
||||||
|
enrollment_required=not administrator_exists,
|
||||||
|
credential_active=active,
|
||||||
|
state="completed" if administrator_exists else state,
|
||||||
|
generation=enrollment.generation if enrollment is not None else 0,
|
||||||
|
expires_at=enrollment.expires_at if enrollment is not None else None,
|
||||||
|
completed_account_id=(
|
||||||
|
enrollment.consumed_account_id if enrollment is not None else None
|
||||||
|
),
|
||||||
|
readiness={
|
||||||
|
"database": True,
|
||||||
|
"access_capability": True,
|
||||||
|
"administrator_absent": not administrator_exists,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def consume_first_admin_credential(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
installation_id: str,
|
||||||
|
provisioner: FirstAdminProvisioner,
|
||||||
|
secret: str,
|
||||||
|
email: str,
|
||||||
|
display_name: str | None,
|
||||||
|
password: str,
|
||||||
|
tenant_slug: str,
|
||||||
|
tenant_name: str,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> FirstAdminEnrollmentResult:
|
||||||
|
current_time = _utc(now)
|
||||||
|
normalized_email = email.strip().casefold()
|
||||||
|
clean_display_name = display_name.strip() if display_name and display_name.strip() else None
|
||||||
|
clean_tenant_slug = tenant_slug.strip().casefold()
|
||||||
|
clean_tenant_name = tenant_name.strip()
|
||||||
|
if not normalized_email or "@" not in normalized_email:
|
||||||
|
raise FirstAdminEnrollmentConflict("Enter a valid administrator email address.")
|
||||||
|
if len(password) < 12:
|
||||||
|
raise FirstAdminEnrollmentConflict("The administrator password must contain at least 12 characters.")
|
||||||
|
if not _TENANT_SLUG_RE.fullmatch(clean_tenant_slug):
|
||||||
|
raise FirstAdminEnrollmentConflict(
|
||||||
|
"The initial tenant slug may contain lowercase letters, numbers, and single hyphens."
|
||||||
|
)
|
||||||
|
if not clean_tenant_name:
|
||||||
|
raise FirstAdminEnrollmentConflict("Enter a name for the initial tenant.")
|
||||||
|
|
||||||
|
request_sha256 = _request_sha256(
|
||||||
|
email=normalized_email,
|
||||||
|
display_name=clean_display_name,
|
||||||
|
tenant_slug=clean_tenant_slug,
|
||||||
|
tenant_name=clean_tenant_name,
|
||||||
|
)
|
||||||
|
supplied_sha256 = _secret_sha256(secret)
|
||||||
|
enrollment = session.execute(
|
||||||
|
select(FirstAdminEnrollment)
|
||||||
|
.where(FirstAdminEnrollment.installation_id == installation_id)
|
||||||
|
.with_for_update()
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if enrollment is None:
|
||||||
|
raise FirstAdminEnrollmentCredentialError("First-admin enrollment is not active.")
|
||||||
|
|
||||||
|
if enrollment.state == FirstAdminEnrollmentState.CONSUMED.value:
|
||||||
|
if (
|
||||||
|
enrollment.token_sha256
|
||||||
|
and hmac.compare_digest(enrollment.token_sha256, supplied_sha256)
|
||||||
|
and enrollment.consumed_request_sha256 == request_sha256
|
||||||
|
and enrollment.consumed_account_id
|
||||||
|
and enrollment.consumed_email
|
||||||
|
):
|
||||||
|
return FirstAdminEnrollmentResult(
|
||||||
|
administrator=FirstSystemAdministratorRef(
|
||||||
|
account_id=enrollment.consumed_account_id,
|
||||||
|
email=enrollment.consumed_email,
|
||||||
|
display_name=enrollment.consumed_display_name,
|
||||||
|
membership_id=enrollment.consumed_membership_id,
|
||||||
|
tenant_id=enrollment.consumed_tenant_id,
|
||||||
|
),
|
||||||
|
replayed=True,
|
||||||
|
)
|
||||||
|
raise FirstAdminEnrollmentCredentialError("The first-admin credential has already been used.")
|
||||||
|
|
||||||
|
if enrollment.state != FirstAdminEnrollmentState.ACTIVE.value or not enrollment.token_sha256:
|
||||||
|
raise FirstAdminEnrollmentCredentialError("First-admin enrollment is not active.")
|
||||||
|
if not _is_future(enrollment.expires_at, current_time):
|
||||||
|
raise FirstAdminEnrollmentCredentialError(
|
||||||
|
"The first-admin credential has expired. A local operator must issue a replacement."
|
||||||
|
)
|
||||||
|
if not hmac.compare_digest(enrollment.token_sha256, supplied_sha256):
|
||||||
|
raise FirstAdminEnrollmentCredentialError("The first-admin credential is invalid.")
|
||||||
|
if provisioner.has_durable_system_administrator(session):
|
||||||
|
raise FirstAdminEnrollmentUnavailable(
|
||||||
|
"A durable system administrator already exists. Bootstrap enrollment is disabled."
|
||||||
|
)
|
||||||
|
|
||||||
|
tenant = session.execute(
|
||||||
|
select(Tenant).where(Tenant.slug == clean_tenant_slug).with_for_update()
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if tenant is None:
|
||||||
|
tenant = Tenant(
|
||||||
|
slug=clean_tenant_slug,
|
||||||
|
name=clean_tenant_name,
|
||||||
|
default_locale="de",
|
||||||
|
settings={},
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
session.add(tenant)
|
||||||
|
session.flush()
|
||||||
|
elif not tenant.is_active:
|
||||||
|
raise FirstAdminEnrollmentConflict("The selected initial tenant is inactive.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
administrator = provisioner.create_first_system_administrator(
|
||||||
|
session,
|
||||||
|
tenant=tenant,
|
||||||
|
email=normalized_email,
|
||||||
|
display_name=clean_display_name,
|
||||||
|
password=password,
|
||||||
|
)
|
||||||
|
except FirstAdminProvisioningError as exc:
|
||||||
|
raise FirstAdminEnrollmentConflict(str(exc)) from exc
|
||||||
|
enrollment.state = FirstAdminEnrollmentState.CONSUMED.value
|
||||||
|
enrollment.consumed_at = current_time
|
||||||
|
enrollment.consumed_account_id = administrator.account_id
|
||||||
|
enrollment.consumed_membership_id = administrator.membership_id
|
||||||
|
enrollment.consumed_tenant_id = administrator.tenant_id
|
||||||
|
enrollment.consumed_email = administrator.email
|
||||||
|
enrollment.consumed_display_name = administrator.display_name
|
||||||
|
enrollment.consumed_request_sha256 = request_sha256
|
||||||
|
session.add(enrollment)
|
||||||
|
_append_event(
|
||||||
|
session,
|
||||||
|
enrollment,
|
||||||
|
event_type="administrator_created",
|
||||||
|
generation=enrollment.generation,
|
||||||
|
created_at=current_time,
|
||||||
|
evidence={
|
||||||
|
"account_id": administrator.account_id,
|
||||||
|
"membership_id": administrator.membership_id,
|
||||||
|
"tenant_id": administrator.tenant_id,
|
||||||
|
"email_sha256": hashlib.sha256(normalized_email.encode("utf-8")).hexdigest(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
audit_event(
|
||||||
|
session,
|
||||||
|
tenant_id=None,
|
||||||
|
scope="system",
|
||||||
|
action="access.first_admin_enrollment.completed",
|
||||||
|
object_type="access_account",
|
||||||
|
object_id=administrator.account_id,
|
||||||
|
details={
|
||||||
|
"generation": enrollment.generation,
|
||||||
|
"membership_id": administrator.membership_id,
|
||||||
|
"tenant_id": administrator.tenant_id,
|
||||||
|
"credential_invalidated": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return FirstAdminEnrollmentResult(administrator=administrator, replayed=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _locked_enrollment(session: Session, installation_id: str) -> FirstAdminEnrollment:
|
||||||
|
enrollment = session.execute(
|
||||||
|
select(FirstAdminEnrollment)
|
||||||
|
.where(FirstAdminEnrollment.installation_id == installation_id)
|
||||||
|
.with_for_update()
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if enrollment is not None:
|
||||||
|
return enrollment
|
||||||
|
enrollment = FirstAdminEnrollment(installation_id=installation_id)
|
||||||
|
try:
|
||||||
|
with session.begin_nested():
|
||||||
|
session.add(enrollment)
|
||||||
|
session.flush()
|
||||||
|
except IntegrityError:
|
||||||
|
enrollment = session.execute(
|
||||||
|
select(FirstAdminEnrollment)
|
||||||
|
.where(FirstAdminEnrollment.installation_id == installation_id)
|
||||||
|
.with_for_update()
|
||||||
|
).scalar_one()
|
||||||
|
return enrollment
|
||||||
|
|
||||||
|
|
||||||
|
def _append_event(
|
||||||
|
session: Session,
|
||||||
|
enrollment: FirstAdminEnrollment,
|
||||||
|
*,
|
||||||
|
event_type: str,
|
||||||
|
generation: int,
|
||||||
|
created_at: datetime,
|
||||||
|
evidence: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
sequence = enrollment.event_count + 1
|
||||||
|
payload = {
|
||||||
|
"installation_id": enrollment.installation_id,
|
||||||
|
"sequence": sequence,
|
||||||
|
"event_type": event_type,
|
||||||
|
"generation": generation,
|
||||||
|
"created_at": created_at.isoformat(),
|
||||||
|
"evidence": evidence,
|
||||||
|
"previous_sha256": enrollment.evidence_head_sha256,
|
||||||
|
}
|
||||||
|
event_sha256 = hashlib.sha256(
|
||||||
|
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
session.add(
|
||||||
|
FirstAdminEnrollmentEvent(
|
||||||
|
installation_id=enrollment.installation_id,
|
||||||
|
sequence=sequence,
|
||||||
|
event_type=event_type,
|
||||||
|
generation=generation,
|
||||||
|
evidence=evidence,
|
||||||
|
previous_sha256=enrollment.evidence_head_sha256,
|
||||||
|
event_sha256=event_sha256,
|
||||||
|
created_at=created_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
enrollment.event_count = sequence
|
||||||
|
enrollment.evidence_head_sha256 = event_sha256
|
||||||
|
session.add(enrollment)
|
||||||
|
|
||||||
|
|
||||||
|
def _request_sha256(
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
display_name: str | None,
|
||||||
|
tenant_slug: str,
|
||||||
|
tenant_name: str,
|
||||||
|
) -> str:
|
||||||
|
payload = {
|
||||||
|
"email": email,
|
||||||
|
"display_name": display_name,
|
||||||
|
"tenant_slug": tenant_slug,
|
||||||
|
"tenant_name": tenant_name,
|
||||||
|
}
|
||||||
|
return hashlib.sha256(
|
||||||
|
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _secret_sha256(secret: str) -> str:
|
||||||
|
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _utc(value: datetime | None) -> datetime:
|
||||||
|
candidate = value or datetime.now(timezone.utc)
|
||||||
|
if candidate.tzinfo is None:
|
||||||
|
return candidate.replace(tzinfo=timezone.utc)
|
||||||
|
return candidate.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_future(value: datetime | None, now: datetime) -> bool:
|
||||||
|
return value is not None and _utc(value) > now
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_reason(value: str) -> str:
|
||||||
|
clean = value.strip()
|
||||||
|
if not clean:
|
||||||
|
raise ValueError("A local operator reason is required.")
|
||||||
|
return clean[:500]
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FirstAdminEnrollment",
|
||||||
|
"FirstAdminEnrollmentConflict",
|
||||||
|
"FirstAdminEnrollmentCredentialError",
|
||||||
|
"FirstAdminEnrollmentError",
|
||||||
|
"FirstAdminEnrollmentEvent",
|
||||||
|
"FirstAdminEnrollmentResult",
|
||||||
|
"FirstAdminEnrollmentState",
|
||||||
|
"FirstAdminEnrollmentStatus",
|
||||||
|
"FirstAdminEnrollmentUnavailable",
|
||||||
|
"IssuedFirstAdminCredential",
|
||||||
|
"consume_first_admin_credential",
|
||||||
|
"first_admin_enrollment_status",
|
||||||
|
"issue_first_admin_credential",
|
||||||
|
]
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from govoplan_core.core.institutional import EvidenceReference, InstitutionalReference
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_FORM_EVIDENCE_PREFIX = "forms_runtime.evidence."
|
||||||
|
|
||||||
|
FormEvidenceState = Literal[
|
||||||
|
"accepted",
|
||||||
|
"pending",
|
||||||
|
"rejected",
|
||||||
|
"expired",
|
||||||
|
"revoked",
|
||||||
|
"unavailable",
|
||||||
|
]
|
||||||
|
|
||||||
|
_FORM_EVIDENCE_STATES = {
|
||||||
|
"accepted",
|
||||||
|
"pending",
|
||||||
|
"rejected",
|
||||||
|
"expired",
|
||||||
|
"revoked",
|
||||||
|
"unavailable",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FormEvidenceContractError(ValueError):
|
||||||
|
"""Stable error for provider-neutral Form evidence operations."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FormEvidenceGrantRequest:
|
||||||
|
"""Request a short-lived, purpose-bound grant from an evidence owner."""
|
||||||
|
|
||||||
|
tenant_id: str
|
||||||
|
instance_id: str
|
||||||
|
definition_ref: InstitutionalReference
|
||||||
|
evidence_kind: str
|
||||||
|
purpose: str
|
||||||
|
idempotency_key: str
|
||||||
|
expires_at: datetime
|
||||||
|
custodian_ref: str | None = None
|
||||||
|
max_size_bytes: int | None = None
|
||||||
|
allowed_content_types: tuple[str, ...] = ()
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_require_text(self.tenant_id, "Form evidence tenant")
|
||||||
|
_require_text(self.instance_id, "Form evidence instance")
|
||||||
|
_require_text(self.evidence_kind, "Form evidence kind")
|
||||||
|
_require_text(self.purpose, "Form evidence purpose")
|
||||||
|
_require_text(self.idempotency_key, "Form evidence idempotency key")
|
||||||
|
if (
|
||||||
|
self.definition_ref.kind != "form"
|
||||||
|
or self.definition_ref.tenant_id != self.tenant_id
|
||||||
|
or not self.definition_ref.version
|
||||||
|
):
|
||||||
|
raise FormEvidenceContractError(
|
||||||
|
"Form evidence grants require an exact same-tenant Form definition."
|
||||||
|
)
|
||||||
|
if self.expires_at.tzinfo is None or self.expires_at.utcoffset() is None:
|
||||||
|
raise FormEvidenceContractError(
|
||||||
|
"Form evidence grant expiry must include a timezone."
|
||||||
|
)
|
||||||
|
if self.max_size_bytes is not None and self.max_size_bytes <= 0:
|
||||||
|
raise FormEvidenceContractError(
|
||||||
|
"Form evidence grant size limits must be positive."
|
||||||
|
)
|
||||||
|
if any(not item.strip() for item in self.allowed_content_types):
|
||||||
|
raise FormEvidenceContractError(
|
||||||
|
"Form evidence content types cannot be empty."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FormEvidenceGrant:
|
||||||
|
provider_id: str
|
||||||
|
grant_id: str
|
||||||
|
upload_token: str | None
|
||||||
|
upload_url: str
|
||||||
|
expires_at: datetime
|
||||||
|
max_size_bytes: int
|
||||||
|
allowed_content_types: tuple[str, ...] = ()
|
||||||
|
replayed: bool = False
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for value, label in (
|
||||||
|
(self.provider_id, "Form evidence provider"),
|
||||||
|
(self.grant_id, "Form evidence grant"),
|
||||||
|
(self.upload_url, "Form evidence upload URL"),
|
||||||
|
):
|
||||||
|
_require_text(value, label)
|
||||||
|
if self.upload_token is not None:
|
||||||
|
_require_text(self.upload_token, "Form evidence upload token")
|
||||||
|
if self.max_size_bytes <= 0:
|
||||||
|
raise FormEvidenceContractError(
|
||||||
|
"Form evidence grant size limits must be positive."
|
||||||
|
)
|
||||||
|
if self.expires_at.tzinfo is None or self.expires_at.utcoffset() is None:
|
||||||
|
raise FormEvidenceContractError(
|
||||||
|
"Form evidence grant expiry must include a timezone."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FormEvidenceInspectionRequest:
|
||||||
|
tenant_id: str
|
||||||
|
instance_id: str
|
||||||
|
definition_ref: InstitutionalReference
|
||||||
|
evidence: EvidenceReference
|
||||||
|
purpose: str
|
||||||
|
final: bool
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_require_text(self.tenant_id, "Form evidence tenant")
|
||||||
|
_require_text(self.instance_id, "Form evidence instance")
|
||||||
|
_require_text(self.purpose, "Form evidence purpose")
|
||||||
|
if self.definition_ref.tenant_id != self.tenant_id:
|
||||||
|
raise FormEvidenceContractError(
|
||||||
|
"Form evidence inspection cannot cross tenants."
|
||||||
|
)
|
||||||
|
if self.evidence.tenant_id != self.tenant_id:
|
||||||
|
raise FormEvidenceContractError(
|
||||||
|
"Form evidence inspection cannot cross tenants."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FormEvidenceInspection:
|
||||||
|
provider_id: str
|
||||||
|
reference: EvidenceReference
|
||||||
|
state: FormEvidenceState
|
||||||
|
observed_at: datetime
|
||||||
|
retryable: bool = False
|
||||||
|
reason: str | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_require_text(self.provider_id, "Form evidence provider")
|
||||||
|
if self.state not in _FORM_EVIDENCE_STATES:
|
||||||
|
raise FormEvidenceContractError(
|
||||||
|
f"Unsupported Form evidence state: {self.state!r}."
|
||||||
|
)
|
||||||
|
if self.observed_at.tzinfo is None or self.observed_at.utcoffset() is None:
|
||||||
|
raise FormEvidenceContractError(
|
||||||
|
"Form evidence inspection time must include a timezone."
|
||||||
|
)
|
||||||
|
if self.state == "accepted" and self.retryable:
|
||||||
|
raise FormEvidenceContractError(
|
||||||
|
"Accepted Form evidence cannot require a retry."
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def accepted(self) -> bool:
|
||||||
|
return self.state == "accepted"
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class FormEvidenceProvider(Protocol):
|
||||||
|
provider_id: str
|
||||||
|
|
||||||
|
def supported_kinds(self) -> Sequence[str]: ...
|
||||||
|
|
||||||
|
def create_upload_grant(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: FormEvidenceGrantRequest,
|
||||||
|
) -> FormEvidenceGrant: ...
|
||||||
|
|
||||||
|
def inspect_evidence(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: FormEvidenceInspectionRequest,
|
||||||
|
) -> FormEvidenceInspection: ...
|
||||||
|
|
||||||
|
|
||||||
|
def form_evidence_capability(provider_id: str) -> str:
|
||||||
|
normalized = str(provider_id or "").strip().lower().replace("-", "_")
|
||||||
|
if not normalized or not normalized.replace("_", "").isalnum():
|
||||||
|
raise FormEvidenceContractError("Invalid Form evidence provider id.")
|
||||||
|
return f"{CAPABILITY_FORM_EVIDENCE_PREFIX}{normalized}"
|
||||||
|
|
||||||
|
|
||||||
|
def form_evidence_provider(
|
||||||
|
registry: object | None,
|
||||||
|
provider_id: str,
|
||||||
|
) -> FormEvidenceProvider | None:
|
||||||
|
capability_name = form_evidence_capability(provider_id)
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(capability_name):
|
||||||
|
return None
|
||||||
|
if hasattr(registry, "require_capability"):
|
||||||
|
provider = registry.require_capability(capability_name)
|
||||||
|
elif hasattr(registry, "capability"):
|
||||||
|
provider = registry.capability(capability_name)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return provider if isinstance(provider, FormEvidenceProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _require_text(value: str, label: str) -> None:
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
raise FormEvidenceContractError(f"{label} is required.")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_FORM_EVIDENCE_PREFIX",
|
||||||
|
"FormEvidenceContractError",
|
||||||
|
"FormEvidenceGrant",
|
||||||
|
"FormEvidenceGrantRequest",
|
||||||
|
"FormEvidenceInspection",
|
||||||
|
"FormEvidenceInspectionRequest",
|
||||||
|
"FormEvidenceProvider",
|
||||||
|
"FormEvidenceState",
|
||||||
|
"form_evidence_capability",
|
||||||
|
"form_evidence_provider",
|
||||||
|
]
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_IDENTITY_TRUST_DIRECTORY = "identity_trust.directory"
|
||||||
|
CAPABILITY_IDENTITY_TRUST_ASSURANCE = "identity_trust.assurance"
|
||||||
|
IDENTITY_TRUST_CONTRACT_VERSION = "1"
|
||||||
|
|
||||||
|
DeviceKeyPurpose = Literal["encryption", "signing", "encryption_and_signing"]
|
||||||
|
DeviceKeyStatus = Literal["active", "revoked", "expired"]
|
||||||
|
TrustSubjectKind = Literal[
|
||||||
|
"identity",
|
||||||
|
"account",
|
||||||
|
"function",
|
||||||
|
"postbox",
|
||||||
|
"external_recipient",
|
||||||
|
]
|
||||||
|
|
||||||
|
_PRIVATE_JWK_FIELDS = frozenset({"d", "p", "q", "dp", "dq", "qi", "oth", "k"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DeviceKeyRegistration:
|
||||||
|
tenant_id: str
|
||||||
|
identity_id: str
|
||||||
|
account_id: str
|
||||||
|
device_id: str
|
||||||
|
key_id: str
|
||||||
|
algorithm: str
|
||||||
|
public_jwk: Mapping[str, object]
|
||||||
|
purpose: DeviceKeyPurpose = "encryption"
|
||||||
|
assurance_level: str = "software"
|
||||||
|
attestation_ref: str | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
idempotency_key: str = ""
|
||||||
|
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract(self.contract_version)
|
||||||
|
for label, value in (
|
||||||
|
("tenant id", self.tenant_id),
|
||||||
|
("identity id", self.identity_id),
|
||||||
|
("account id", self.account_id),
|
||||||
|
("device id", self.device_id),
|
||||||
|
("key id", self.key_id),
|
||||||
|
("algorithm", self.algorithm),
|
||||||
|
("idempotency key", self.idempotency_key),
|
||||||
|
):
|
||||||
|
_require_text(value, label)
|
||||||
|
if not self.public_jwk or _PRIVATE_JWK_FIELDS & set(self.public_jwk):
|
||||||
|
raise ValueError("Only a bounded public JWK may be registered")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DeviceKeyRef:
|
||||||
|
tenant_id: str
|
||||||
|
identity_id: str
|
||||||
|
account_id: str
|
||||||
|
device_id: str
|
||||||
|
key_id: str
|
||||||
|
algorithm: str
|
||||||
|
public_jwk: Mapping[str, object]
|
||||||
|
purpose: DeviceKeyPurpose
|
||||||
|
assurance_level: str
|
||||||
|
status: DeviceKeyStatus
|
||||||
|
epoch: int
|
||||||
|
registered_at: datetime
|
||||||
|
attestation_ref: str | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
revoked_at: datetime | None = None
|
||||||
|
revocation_reason: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class KeyEpochRotationRequest:
|
||||||
|
tenant_id: str
|
||||||
|
subject_kind: TrustSubjectKind
|
||||||
|
subject_id: str
|
||||||
|
reason: str
|
||||||
|
access_decision_ref: str
|
||||||
|
idempotency_key: str
|
||||||
|
history_policy: str = "all_retained"
|
||||||
|
previous_epoch: int | None = None
|
||||||
|
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract(self.contract_version)
|
||||||
|
for label, value in (
|
||||||
|
("tenant id", self.tenant_id),
|
||||||
|
("subject id", self.subject_id),
|
||||||
|
("reason", self.reason),
|
||||||
|
("access decision reference", self.access_decision_ref),
|
||||||
|
("idempotency key", self.idempotency_key),
|
||||||
|
):
|
||||||
|
_require_text(value, label)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class KeyEpochRef:
|
||||||
|
tenant_id: str
|
||||||
|
subject_kind: TrustSubjectKind
|
||||||
|
subject_id: str
|
||||||
|
epoch: int
|
||||||
|
state: Literal["active", "superseded", "revoked"]
|
||||||
|
history_policy: str
|
||||||
|
effective_at: datetime
|
||||||
|
previous_epoch: int | None = None
|
||||||
|
reason: str | None = None
|
||||||
|
access_decision_ref: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class KeyAccessRequest:
|
||||||
|
tenant_id: str
|
||||||
|
account_id: str
|
||||||
|
device_key_id: str
|
||||||
|
subject_kind: TrustSubjectKind
|
||||||
|
subject_id: str
|
||||||
|
key_epoch: int
|
||||||
|
access_decision_ref: str
|
||||||
|
purpose: str
|
||||||
|
requested_at: datetime
|
||||||
|
function_assignment_id: str | None = None
|
||||||
|
delegation_id: str | None = None
|
||||||
|
resource_ref: str | None = None
|
||||||
|
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract(self.contract_version)
|
||||||
|
if self.key_epoch < 1:
|
||||||
|
raise ValueError("Key epoch must be positive")
|
||||||
|
for label, value in (
|
||||||
|
("tenant id", self.tenant_id),
|
||||||
|
("account id", self.account_id),
|
||||||
|
("device key id", self.device_key_id),
|
||||||
|
("subject id", self.subject_id),
|
||||||
|
("access decision reference", self.access_decision_ref),
|
||||||
|
("purpose", self.purpose),
|
||||||
|
):
|
||||||
|
_require_text(value, label)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class KeyAccessDecision:
|
||||||
|
allowed: bool
|
||||||
|
decision_ref: str
|
||||||
|
reason: str
|
||||||
|
device_key: DeviceKeyRef | None = None
|
||||||
|
epoch: KeyEpochRef | None = None
|
||||||
|
audit_event_ref: str | None = None
|
||||||
|
requirements: tuple[str, ...] = ()
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AssuranceCheckRequest:
|
||||||
|
tenant_id: str
|
||||||
|
account_id: str
|
||||||
|
purpose: str
|
||||||
|
minimum_level: str
|
||||||
|
evidence_ref: str
|
||||||
|
evaluated_at: datetime
|
||||||
|
maximum_age_seconds: int = 300
|
||||||
|
device_key_id: str | None = None
|
||||||
|
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract(self.contract_version)
|
||||||
|
if self.maximum_age_seconds < 1:
|
||||||
|
raise ValueError("Assurance maximum age must be positive")
|
||||||
|
for label, value in (
|
||||||
|
("tenant id", self.tenant_id),
|
||||||
|
("account id", self.account_id),
|
||||||
|
("purpose", self.purpose),
|
||||||
|
("minimum level", self.minimum_level),
|
||||||
|
("evidence reference", self.evidence_ref),
|
||||||
|
):
|
||||||
|
_require_text(value, label)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AssuranceDecision:
|
||||||
|
allowed: bool
|
||||||
|
reason: str
|
||||||
|
assurance_level: str | None = None
|
||||||
|
evidence_ref: str | None = None
|
||||||
|
verified_at: datetime | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class IdentityTrustDirectory(Protocol):
|
||||||
|
def register_device_key(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: DeviceKeyRegistration,
|
||||||
|
) -> DeviceKeyRef: ...
|
||||||
|
|
||||||
|
def revoke_device_key(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
key_id: str,
|
||||||
|
expected_epoch: int,
|
||||||
|
reason: str,
|
||||||
|
) -> DeviceKeyRef: ...
|
||||||
|
|
||||||
|
def list_device_keys(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
account_id: str,
|
||||||
|
active_only: bool = True,
|
||||||
|
) -> tuple[DeviceKeyRef, ...]: ...
|
||||||
|
|
||||||
|
def rotate_epoch(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: KeyEpochRotationRequest,
|
||||||
|
) -> KeyEpochRef: ...
|
||||||
|
|
||||||
|
def resolve_epoch(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject_kind: TrustSubjectKind,
|
||||||
|
subject_id: str,
|
||||||
|
epoch: int | None = None,
|
||||||
|
) -> KeyEpochRef | None: ...
|
||||||
|
|
||||||
|
def decide_key_access(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: KeyAccessRequest,
|
||||||
|
) -> KeyAccessDecision: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class IdentityTrustAssurance(Protocol):
|
||||||
|
def verify_assurance(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: AssuranceCheckRequest,
|
||||||
|
) -> AssuranceDecision: ...
|
||||||
|
|
||||||
|
|
||||||
|
def identity_trust_directory(
|
||||||
|
registry: object | None,
|
||||||
|
) -> IdentityTrustDirectory | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_IDENTITY_TRUST_DIRECTORY)
|
||||||
|
return capability if isinstance(capability, IdentityTrustDirectory) else None
|
||||||
|
|
||||||
|
|
||||||
|
def identity_trust_assurance(
|
||||||
|
registry: object | None,
|
||||||
|
) -> IdentityTrustAssurance | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_IDENTITY_TRUST_ASSURANCE)
|
||||||
|
return capability if isinstance(capability, IdentityTrustAssurance) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _capability(registry: object | None, name: str) -> object | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(name)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return registry.capability(name)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_contract(value: str) -> None:
|
||||||
|
if value != IDENTITY_TRUST_CONTRACT_VERSION:
|
||||||
|
raise ValueError("Unsupported identity-trust contract version")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_text(value: str, label: str) -> None:
|
||||||
|
if not value.strip():
|
||||||
|
raise ValueError(f"{label.capitalize()} is required")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AssuranceCheckRequest",
|
||||||
|
"AssuranceDecision",
|
||||||
|
"CAPABILITY_IDENTITY_TRUST_ASSURANCE",
|
||||||
|
"CAPABILITY_IDENTITY_TRUST_DIRECTORY",
|
||||||
|
"DeviceKeyRef",
|
||||||
|
"DeviceKeyRegistration",
|
||||||
|
"IdentityTrustAssurance",
|
||||||
|
"IdentityTrustDirectory",
|
||||||
|
"KeyAccessDecision",
|
||||||
|
"KeyAccessRequest",
|
||||||
|
"KeyEpochRef",
|
||||||
|
"KeyEpochRotationRequest",
|
||||||
|
"identity_trust_assurance",
|
||||||
|
"identity_trust_directory",
|
||||||
|
]
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Literal, Protocol, runtime_checkable
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
IDM_MODULE_ID = "idm"
|
IDM_MODULE_ID = "idm"
|
||||||
CAPABILITY_IDM_DIRECTORY = "idm.directory"
|
CAPABILITY_IDM_DIRECTORY = "idm.directory"
|
||||||
|
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS = "idm.function_assignments"
|
||||||
|
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE = "idm.assignment_lifecycle"
|
||||||
|
CAPABILITY_IDM_RELATIONSHIPS = "idm.relationships"
|
||||||
|
|
||||||
IdmStatus = Literal["active", "inactive", "suspended"]
|
IdmStatus = Literal["active", "inactive", "suspended"]
|
||||||
OrganizationFunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
|
OrganizationFunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
|
||||||
|
TypedGroupStatus = Literal["active", "inactive"]
|
||||||
|
IdentityRelationshipStatus = Literal["active", "revoked"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -30,6 +35,90 @@ class OrganizationFunctionAssignmentRef:
|
|||||||
status: IdmStatus = "active"
|
status: IdmStatus = "active"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OrganizationFunctionIncumbencyRef:
|
||||||
|
tenant_id: str
|
||||||
|
function_id: str
|
||||||
|
assignments: tuple[OrganizationFunctionAssignmentRef, ...] = ()
|
||||||
|
function_active: bool = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def vacant(self) -> bool:
|
||||||
|
return not self.assignments
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TypedGroupRef:
|
||||||
|
"""Provider-neutral IDM group fact scoped to one tenant."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
key: str
|
||||||
|
name: str
|
||||||
|
group_type: str
|
||||||
|
description: str | None = None
|
||||||
|
status: TypedGroupStatus = "active"
|
||||||
|
source_provider: str = "local"
|
||||||
|
source_resource_type: str | None = None
|
||||||
|
source_resource_id: str | None = None
|
||||||
|
source_revision: str | None = None
|
||||||
|
properties: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
revision: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IdentityRelationshipRef:
|
||||||
|
"""An effective-dated relationship from an identity to a typed target."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
relationship_kind: str
|
||||||
|
subject_identity_id: str
|
||||||
|
target_group_id: str | None = None
|
||||||
|
related_identity_id: str | None = None
|
||||||
|
role: str | None = None
|
||||||
|
valid_from: datetime | None = None
|
||||||
|
valid_until: datetime | None = None
|
||||||
|
status: IdentityRelationshipStatus = "active"
|
||||||
|
revoked_at: datetime | None = None
|
||||||
|
revoked_by: str | None = None
|
||||||
|
revocation_reason: str | None = None
|
||||||
|
source_provider: str = "local"
|
||||||
|
source_resource_type: str | None = None
|
||||||
|
source_resource_id: str | None = None
|
||||||
|
source_revision: str | None = None
|
||||||
|
properties: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
revision: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IdentityRelationshipDecisionRef:
|
||||||
|
relationship: IdentityRelationshipRef
|
||||||
|
included: bool
|
||||||
|
code: str
|
||||||
|
explanation: str
|
||||||
|
identity_status: IdmStatus | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TypedGroupMembershipResolutionRef:
|
||||||
|
group: TypedGroupRef
|
||||||
|
effective_at: datetime
|
||||||
|
decisions: tuple[IdentityRelationshipDecisionRef, ...] = ()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identity_ids(self) -> tuple[str, ...]:
|
||||||
|
return tuple(
|
||||||
|
dict.fromkeys(
|
||||||
|
item.relationship.subject_identity_id
|
||||||
|
for item in self.decisions
|
||||||
|
if item.included
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class IdmDirectory(Protocol):
|
class IdmDirectory(Protocol):
|
||||||
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
|
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
|
||||||
@@ -40,6 +129,7 @@ class IdmDirectory(Protocol):
|
|||||||
identity_id: str,
|
identity_id: str,
|
||||||
*,
|
*,
|
||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
||||||
...
|
...
|
||||||
|
|
||||||
@@ -48,5 +138,153 @@ class IdmDirectory(Protocol):
|
|||||||
account_id: str,
|
account_id: str,
|
||||||
*,
|
*,
|
||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def organization_function_assignments_for_identities(
|
||||||
|
self,
|
||||||
|
identity_ids: Sequence[str],
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
) -> Mapping[str, Sequence[OrganizationFunctionAssignmentRef]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def organization_function_assignments_for_accounts(
|
||||||
|
self,
|
||||||
|
account_ids: Sequence[str],
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
) -> Mapping[str, Sequence[OrganizationFunctionAssignmentRef]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class IdmFunctionAssignmentDirectory(Protocol):
|
||||||
|
"""Reverse lookup for effective incumbency and vacancy decisions."""
|
||||||
|
|
||||||
|
def organization_function_assignments_for_function(
|
||||||
|
self,
|
||||||
|
function_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def organization_function_incumbencies(
|
||||||
|
self,
|
||||||
|
function_ids: Sequence[str],
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
) -> Mapping[str, OrganizationFunctionIncumbencyRef]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class IdmRelationshipDirectory(Protocol):
|
||||||
|
"""Tenant-safe forward/reverse lookup for typed IDM relationships."""
|
||||||
|
|
||||||
|
def get_typed_group(
|
||||||
|
self,
|
||||||
|
group_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
) -> TypedGroupRef | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def list_typed_groups(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
query: str | None = None,
|
||||||
|
group_types: Sequence[str] = (),
|
||||||
|
include_inactive: bool = False,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[TypedGroupRef]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def identity_relationships_for_identity(
|
||||||
|
self,
|
||||||
|
identity_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
relationship_kinds: Sequence[str] = (),
|
||||||
|
) -> Sequence[IdentityRelationshipRef]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def identity_relationships_for_identities(
|
||||||
|
self,
|
||||||
|
identity_ids: Sequence[str],
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
relationship_kinds: Sequence[str] = (),
|
||||||
|
) -> Mapping[str, Sequence[IdentityRelationshipRef]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def identity_relationships_for_group(
|
||||||
|
self,
|
||||||
|
group_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
relationship_kinds: Sequence[str] = (),
|
||||||
|
) -> Sequence[IdentityRelationshipRef]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def identity_relationships_for_groups(
|
||||||
|
self,
|
||||||
|
group_ids: Sequence[str],
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
relationship_kinds: Sequence[str] = (),
|
||||||
|
) -> Mapping[str, Sequence[IdentityRelationshipRef]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def resolve_typed_group_memberships(
|
||||||
|
self,
|
||||||
|
group_ids: Sequence[str],
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
relationship_kinds: Sequence[str] = ("member",),
|
||||||
|
) -> Mapping[str, TypedGroupMembershipResolutionRef]:
|
||||||
|
...
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class IdmAssignmentLifecycle(Protocol):
|
||||||
|
"""Worker boundary for time-driven function-assignment transitions."""
|
||||||
|
|
||||||
|
def process_expired(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def idm_assignment_lifecycle(
|
||||||
|
registry: object | None,
|
||||||
|
) -> IdmAssignmentLifecycle | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE)
|
||||||
|
return (
|
||||||
|
capability
|
||||||
|
if isinstance(capability, IdmAssignmentLifecycle)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user