Compare commits
119
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6591aaa3fd | ||
|
|
dc1f244f17 | ||
|
|
a6d056a3df | ||
|
|
c3daa4a9aa | ||
|
|
c209b3c27d | ||
|
|
32c70a4657 | ||
|
|
b75ca34295 | ||
|
|
ac40774785 | ||
|
|
9a3008002d | ||
|
|
9cb2080938 | ||
|
|
08c3e47b6d | ||
|
|
6e518fa6a2 | ||
|
|
f98cf9ced8 | ||
|
|
d2e491348d | ||
|
|
562d278f60 | ||
|
|
c6f6faf64f | ||
|
|
1c3ee9e8c7 | ||
|
|
aa91063211 | ||
|
|
fa2d5d40dd | ||
|
|
6ccef162f6 | ||
|
|
48dac139a5 | ||
|
|
a090e5af20 | ||
|
|
0c1358b862 | ||
|
|
a9035c4c3b | ||
|
|
137c7c005f | ||
|
|
8eeea968f2 | ||
|
|
0ca6568005 | ||
|
|
af90db44c9 | ||
|
|
5de46e9c0e | ||
|
|
1d9b677c1b | ||
|
|
54178ee56c | ||
|
|
10e7597612 | ||
|
|
142ccbc587 | ||
|
|
f75ad48d78 | ||
|
|
5a9e8f79f9 | ||
|
|
fbea74a74b | ||
|
|
925dc33696 | ||
|
|
4b0737e1cd | ||
|
|
4f4007aff1 | ||
|
|
8e687c4420 | ||
|
|
604f20eed7 | ||
|
|
6a2da94e47 | ||
|
|
e121ca900e | ||
|
|
79629c5a2c | ||
|
|
026e451aa4 | ||
|
|
f11c675d11 | ||
|
|
0fae09ba3c | ||
|
|
8f642bd618 | ||
|
|
6643c8fc1e | ||
|
|
fd90b60430 | ||
|
|
0aae6f0539 | ||
|
|
be7b79612c | ||
|
|
557c77670b | ||
|
|
d277218784 | ||
|
|
cf16a7b27a | ||
|
|
51bf14f376 | ||
|
|
8d9bcfd8b5 | ||
|
|
3c7a593f63 | ||
|
|
9d1352ba30 | ||
|
|
4cf2bfeb3e | ||
|
|
94c94fefb4 | ||
|
|
d600bca374 | ||
|
|
ffaab543d2 | ||
|
|
41db78c201 | ||
|
|
c042244da8 | ||
|
|
5a2e99f496 | ||
|
|
8a925782ab | ||
|
|
7685a103e8 | ||
|
|
ee5c881df9 | ||
|
|
6814a41ae4 | ||
|
|
dd7ad4d9c7 | ||
|
|
934db6d44b | ||
|
|
d307e29145 | ||
|
|
ff88142471 | ||
|
|
887e9beb9e | ||
|
|
e6457b3f6b | ||
|
|
eb0c01c5d2 | ||
|
|
40cc012124 | ||
|
|
44196f5620 | ||
|
|
9ceb1b8c22 | ||
|
|
32c234fbdb | ||
|
|
d65d7a8e5f | ||
|
|
b5f5be15f6 | ||
|
|
f5949427cc | ||
|
|
5d1287735e | ||
|
|
7ea0cb8655 | ||
|
|
b553513c9f | ||
|
|
b5a4eb177a | ||
|
|
f1a5be2a93 | ||
|
|
add7a99f6d | ||
|
|
982ef636b8 | ||
|
|
9aad49f16d | ||
|
|
2b5c14385d | ||
|
|
bca3e46293 | ||
|
|
f09d2bf9df | ||
|
|
702421be48 | ||
|
|
bb471df21c | ||
|
|
bfb0d7d7c9 | ||
|
|
1974bf1a2b | ||
|
|
0c9bf6758c | ||
|
|
25da7d49a9 | ||
|
|
40c10089ab | ||
|
|
d6e7c8b0b1 | ||
|
|
5bc7d748f8 | ||
|
|
7117673ecc | ||
|
|
14351b0c94 | ||
|
|
ad57fad1ea | ||
|
|
fa32cca03f | ||
|
|
2d0551a845 | ||
|
|
bb84122061 | ||
|
|
b823a22b9b | ||
|
|
70fc6da811 | ||
|
|
729b84d3af | ||
|
|
b962f6756e | ||
|
|
79d00b84e3 | ||
|
|
842be5edb5 | ||
|
|
7e59a7f2b3 | ||
|
|
5bfbe9a887 | ||
|
|
01f91154e0 |
@@ -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/.template-preview-test-build/
|
||||
webui/.import-test-build/
|
||||
webui/dist-conformance/
|
||||
webui/test-results/
|
||||
|
||||
# Security audit reports
|
||||
audit-reports/
|
||||
|
||||
@@ -33,6 +33,8 @@ Canonical policy documents live in `docs/`:
|
||||
- [ACCESS_RBAC_MODEL.md](docs/ACCESS_RBAC_MODEL.md)
|
||||
- [GOVERNANCE_MODEL.md](docs/GOVERNANCE_MODEL.md)
|
||||
- [MODULE_ARCHITECTURE.md](docs/MODULE_ARCHITECTURE.md)
|
||||
- [INTEGRITY_PERFORMANCE_CONTRACT.md](docs/INTEGRITY_PERFORMANCE_CONTRACT.md)
|
||||
- [TABULAR_SOURCE_CONTRACT.md](docs/TABULAR_SOURCE_CONTRACT.md)
|
||||
- [DEPLOYMENT_OPERATOR_GUIDE.md](docs/DEPLOYMENT_OPERATOR_GUIDE.md)
|
||||
- [CODEX_WORKFLOW.md](docs/CODEX_WORKFLOW.md)
|
||||
|
||||
@@ -117,7 +119,7 @@ CI runs the `ci` profile in report-only mode and uploads `audit-reports/` as an
|
||||
artifact. Once the baseline is clean, set `SECURITY_AUDIT_FAIL_ON_FINDINGS=1`
|
||||
or pass `--strict` locally to turn findings into a failing gate.
|
||||
|
||||
`govoplan_core.devserver` enables the development bootstrap before loading settings. In dev, startup migrations create or upgrade the schema and the bootstrap creates the default development login if needed. Explicitly setting `DEV_BOOTSTRAP_ENABLED=false` disables this convenience. Production deployments should use migrations and managed database provisioning instead.
|
||||
`govoplan_core.devserver` enables the development bootstrap before loading settings. In dev, startup migrations create or upgrade the schema and the bootstrap creates the default development login if needed. Explicitly setting `DEV_BOOTSTRAP_ENABLED=false` disables this convenience. Production deployments use the separate, expiring single-use flow exposed by `python -m govoplan_core.commands.first_admin`; it cannot enable or consume development bootstrap credentials.
|
||||
|
||||
To verify the effective runtime paths and bootstrap behavior without starting uvicorn, run the smoke mode:
|
||||
|
||||
|
||||
@@ -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,21 @@
|
||||
"""Development-track wrapper for the ownership history repair."""
|
||||
|
||||
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" / "c58a2d7e9f10_ownership_decision_history.py"
|
||||
_spec = spec_from_file_location("govoplan_ownership_decision_history_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"
|
||||
/ "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
|
||||
+6
-1
@@ -12,7 +12,10 @@ except ModuleNotFoundError as exc:
|
||||
raise
|
||||
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
|
||||
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core import first_admin as core_first_admin_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core import ownership as core_ownership_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core import recovery as core_recovery_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core import runtime_coordination as core_runtime_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core.migrations import migration_metadata_plan
|
||||
from govoplan_core.db.base import Base
|
||||
@@ -23,7 +26,9 @@ from govoplan_core.tenancy.scope import scope_registry
|
||||
|
||||
config = context.config
|
||||
database_url = config.attributes.get("database_url") or settings.database_url
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
# Alembic stores options through ConfigParser: escape only its interpolation
|
||||
# syntax so URL-encoded credentials/socket paths reach SQLAlchemy unchanged.
|
||||
config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%"))
|
||||
|
||||
if config.config_file_name is not None:
|
||||
# Migrations can run inside the long-lived application process when module
|
||||
|
||||
@@ -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,37 @@
|
||||
"""repair decision history on previously upgraded ownership tables
|
||||
|
||||
Revision ID: c58a2d7e9f10
|
||||
Revises: b47e6f809a13
|
||||
Create Date: 2026-09-07
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c58a2d7e9f10"
|
||||
down_revision = "b47e6f809a13"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# The original ownership migration gained this column after some databases
|
||||
# had already applied it. create_all/checkfirst cannot upgrade those tables.
|
||||
# Fresh installations already have it; never replace their audit evidence.
|
||||
columns = {column["name"] for column in sa.inspect(op.get_bind()).get_columns(
|
||||
"core_ownership_transfers"
|
||||
)}
|
||||
if "decisions" not in columns:
|
||||
op.add_column(
|
||||
"core_ownership_transfers",
|
||||
sa.Column("decisions", sa.JSON(), nullable=False, server_default=sa.text("'[]'")),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Older installations and fresh installations at the preceding revision
|
||||
# differ. Keep the additive column and any subsequently recorded evidence.
|
||||
pass
|
||||
@@ -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")
|
||||
@@ -142,6 +142,7 @@ system:tenants:read
|
||||
system:tenants:create
|
||||
system:tenants:update
|
||||
system:tenants:suspend
|
||||
system:tenants:erase
|
||||
|
||||
system:accounts:read
|
||||
system:accounts:create
|
||||
|
||||
@@ -47,6 +47,9 @@ Recommended fields:
|
||||
irreversible
|
||||
- expected effects
|
||||
- idempotency key strategy
|
||||
- recovery mode: atomic, compensating, snapshot restore, forward recovery, or
|
||||
irreversible
|
||||
- concrete verification steps which prove whether the effect occurred
|
||||
- audit event names
|
||||
- preview provider
|
||||
|
||||
@@ -89,10 +92,14 @@ The runner should execute an action plan as follows:
|
||||
4. Run permission and policy checks.
|
||||
5. Generate a consequence preview.
|
||||
6. Reserve or verify the idempotency key.
|
||||
7. Execute the owning module capability.
|
||||
8. Record observed effects.
|
||||
9. Emit events and audit records.
|
||||
10. Mark the command complete, retryable, quarantined, or requiring manual
|
||||
7. Create a durable recovery operation and acquire its execution fence.
|
||||
8. Persist dispatch evidence before a non-atomic provider call.
|
||||
9. Execute the owning module capability.
|
||||
10. Verify the provider result and every announced effect using the action's
|
||||
declared recovery checks.
|
||||
11. Commit the local projection and verified recovery checkpoint together.
|
||||
12. Emit events and audit records.
|
||||
13. Mark the command complete, retryable, quarantined, or requiring manual
|
||||
intervention.
|
||||
|
||||
The runner must never advance workflow state past a required side effect unless
|
||||
@@ -110,7 +117,16 @@ between:
|
||||
6. reconciled, corrected, or compensated outcome.
|
||||
|
||||
An API timeout after dispatch is not a failed effect and must not be retried as
|
||||
a fresh command. The actor context should retain the real identity/account,
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Shared API client cache and authority boundaries
|
||||
|
||||
All optional WebUI modules use the Core API client. Its bounded in-memory caches
|
||||
are an optimization, never an authorization mechanism. The backend must check
|
||||
the current principal, tenant, and permissions even for conditional GETs.
|
||||
|
||||
- Identical simultaneous safe requests can share one network request. Requests
|
||||
with caller-owned cancellation are independent.
|
||||
- Responses allowing reuse have at most a 750 ms recent-response window.
|
||||
`no-store` and `Vary: *` responses are not retained. `no-cache` and zero-age
|
||||
responses require a server check; permitted ETags retain conditional GET
|
||||
support without bypassing authorization. This follows the relevant
|
||||
[HTTP cache-control semantics](https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2).
|
||||
- Explicit `cache: "no-store"`, `"reload"`, or `"no-cache"` reads bypass older
|
||||
response data and supersede older requests for that resource. Reload is not a
|
||||
mutation. Owning read helpers must pass these options through pagination.
|
||||
- Writes invalidate caches before execution and again on settlement, including
|
||||
failures whose server outcome may be uncertain. Reads started before or
|
||||
during the write cannot seed reusable data after it finishes.
|
||||
- The shell calls `clearApiReadCache()` before explicit auth updates and when
|
||||
refreshing authoritative session data. API-settings changes, clearing the
|
||||
token, authentication expiry, and changes to the paired session/CSRF cookie
|
||||
also invalidate both stored and in-flight reuse. Cookie observation also
|
||||
covers sign-in/out in another tab; it does not read the HttpOnly session token.
|
||||
- Interactive sign-in and sign-out clear a previously saved automation key.
|
||||
Explicit key-based connection settings still select the key's identity;
|
||||
profile-only updates preserve settings identity to avoid reload loops.
|
||||
- Expired responses from superseded reads or downloads do not trigger a login
|
||||
prompt in a newer session.
|
||||
- Every completion (including 304) must still own its cache slot and generation
|
||||
before storing anything. An old caller may receive its own result, so feature
|
||||
components must continue guarding displayed state against obsolete requests.
|
||||
|
||||
Regression coverage: `npm run test:api-client-cache` uses the real client and
|
||||
isolated network fixtures. No live API or account data is involved.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Disposable resource-bounded operations
|
||||
|
||||
`security.bounded_process.run_bounded_operation` runs a trusted, importable,
|
||||
module-level `bytes -> bytes` function in a fresh interpreter. Core owns the
|
||||
process lifecycle, not the business parser. Owners retain authorization,
|
||||
sessions, provider reads, idempotency and persistence in the parent and pass
|
||||
only explicit bounded data. Never accept the operation, module or source path
|
||||
from a client. Use `security.worker_payload` for typed values; it does not use
|
||||
pickle, arbitrary constructors or JSON object hooks.
|
||||
|
||||
The runner requires POSIX process groups, `waitid(WNOWAIT)` and resource limits.
|
||||
Unsupported controls fail closed; there is no in-process fallback. The child
|
||||
uses `-I -B`, a fixed minimal environment, `/` as working directory, closed
|
||||
inherited descriptors and a new process session. Limits are installed before
|
||||
the owning module is imported. Installed dependencies must support isolated
|
||||
Python imports; development `PYTHONPATH` alone is insufficient.
|
||||
|
||||
`ProcessLimits` specifies wall-clock seconds (including child startup), CPU
|
||||
seconds, virtual address space, input/output pipe bytes and maximum regular-file
|
||||
size. Defaults are 10 seconds wall/CPU, 256 MiB address space, 8 MiB input and
|
||||
output, and no regular-file output. Wall/CPU limits are at most 600 seconds,
|
||||
memory 64 MiB–8 GiB, pipe limits 1 byte–256 MiB, and file size 0–2 GiB. Owners
|
||||
must document their tighter functional limits; a transport cap does not replace
|
||||
row, archive expansion, item-count or artifact limits.
|
||||
|
||||
Admission is non-queuing. `GOVOPLAN_ISOLATED_PROCESS_CONCURRENCY` defaults to 1
|
||||
(range 1–16) and applies across these operations **within each API/worker
|
||||
process**. Multiply capacity and memory budgets by the number of API/worker
|
||||
processes when sizing an installation. This is not a fleet-wide semaphore,
|
||||
cgroup quota, filesystem/network sandbox or permission to run arbitrary code.
|
||||
`RLIMIT_FSIZE` is per file, not a total disk quota. Owners creating staged files
|
||||
must enforce cumulative quotas and clean up their own private directories.
|
||||
|
||||
Owners preparing bounded local snapshots can enter
|
||||
`bounded_operation_admission()` before preparation and pass its token as
|
||||
`admission=` to the runner. This reuses shared capacity rather than reserving a
|
||||
second slot. Tokens belong to their active context, thread and process; expired,
|
||||
cross-thread and overlapping reuse fail. Preparation exceptions release the
|
||||
slot without launching a child. Never hold admission while waiting for a user;
|
||||
parent-side preparation still requires explicit I/O and byte bounds.
|
||||
|
||||
The parent concurrently drains stdout/stderr while writing input. Output is
|
||||
bounded during reading, stderr is discarded and capped at 64 KiB, and raw child
|
||||
tracebacks are never returned. Every success, exception, timeout, cancellation
|
||||
and callback failure kills the owned process group before reaping its leader,
|
||||
including descendants which close their inherited pipes. A module-level child
|
||||
handler must return bytes; it must not print logs/progress to stdout.
|
||||
|
||||
The optional `cancelled` callback runs in the parent at most roughly every
|
||||
50 ms while waiting. It must be fast and must not return an awaitable. It may
|
||||
also service a module-owned bounded progress protocol; exceptions terminate
|
||||
the child and propagate. There is no fabricated progress for killed work.
|
||||
`ProcessBudgetError.code` distinguishes busy, cancelled, timeout, CPU, memory,
|
||||
input/output limits, unavailable controls and worker failure. Owners map these
|
||||
to their existing structured diagnostics and recovery semantics.
|
||||
|
||||
The private typed-data codec supports null, booleans, strings, bytes, integers,
|
||||
floats, Decimal, UUID, date/time/datetime, lists, tuples and string-keyed maps.
|
||||
It rejects unsupported objects, malformed/trailing bytes, duplicate keys,
|
||||
excess depth (64) and node counts (1,000,000). Operation DTOs remain owner
|
||||
contracts and require owner validation. Do not persist this private wire format
|
||||
or use it as a public API.
|
||||
|
||||
Tests use real child processes for catastrophic regex, memory exhaustion,
|
||||
noisy output, exact limits, cancellation, closed-pipe hangs and descendant
|
||||
cleanup. These are local process regression tests, not production concurrent
|
||||
load certification. Operators still need target Linux/cgroup, cancellation,
|
||||
worker-count, memory and disk-quota evidence before raising concurrency.
|
||||
|
||||
## Deutsche Betriebszusammenfassung
|
||||
|
||||
Rechenintensive, vertrauenswürdige Moduloperationen laufen in einem frischen
|
||||
Prozess mit harten Laufzeit-, CPU-, Speicher- und Ausgabegrenzen. Berechtigungen,
|
||||
Sitzungen, Zugangsdaten und Datenbankänderungen bleiben im Hauptprozess. Fehlende
|
||||
Betriebssystemkontrollen führen zu einer Diagnose, nicht zu ungeschützter
|
||||
Ausführung. `GOVOPLAN_ISOLATED_PROCESS_CONCURRENCY` begrenzt die gemeinsame
|
||||
Zulassung je API-/Worker-Prozess, standardmäßig auf 1. Mehrere Prozesse haben
|
||||
jeweils eigene Grenzen; systemweite Speicher- und Festplattenquoten müssen
|
||||
Betreiber zusätzlich konfigurieren und auf der Zielinstallation prüfen. Die
|
||||
Schnittstelle ist keine Sandbox für beliebigen Code. Modul-Dokumentation nennt
|
||||
die jeweiligen fachlichen Grenzen, Fortschritts- und Wiederholungsregeln.
|
||||
@@ -31,6 +31,7 @@ such as Redis degradation and language fallback are not compatibility paths.
|
||||
| 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
|
||||
|
||||
|
||||
@@ -157,9 +157,61 @@ The initial implementation includes provider-neutral orchestration helpers:
|
||||
- `apply_configuration_package(...)`
|
||||
- `export_configuration_package(...)`
|
||||
|
||||
Portable fragments may bind deployment-specific operator input without placing
|
||||
that value in the signed reusable definition. A payload value of
|
||||
`{"$data": "requirement_key"}` references a key declared in the manifest's
|
||||
`data_requirements`. Preflight fails before invoking the owning provider when a
|
||||
reference is malformed, undeclared, or unresolved. Once supplied, Core replaces
|
||||
the reference in memory and passes only the resolved fragment to the provider.
|
||||
This mechanism is for deployment bindings and wording, not plaintext secrets:
|
||||
credential-envelope or environment references remain the normal portable
|
||||
boundary.
|
||||
|
||||
The first concrete provider is `govoplan_access.backend.configuration_provider`.
|
||||
It supports access-owned `roles`, `groups`, and `group_role_assignments`
|
||||
fragments and applies them idempotently.
|
||||
fragments and applies them idempotently. Mail and Files also register providers
|
||||
for deployment configuration: Mail owns receipt-bound SMTP profiles and Files
|
||||
validates the deployment-owned managed-storage binding.
|
||||
|
||||
### Deployment capability receipt
|
||||
|
||||
The installer mounts a bounded, non-secret infrastructure receipt at the path
|
||||
named by `GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH`. Core validates that document
|
||||
once for configuration-package context and exposes typed capability and
|
||||
post-install-task records to providers. Invalid receipts fail closed. Endpoint
|
||||
metadata is sanitized, and secret fields may cross this boundary only as
|
||||
`env:VARIABLE_NAME` references.
|
||||
|
||||
Feature providers remain responsible for their own semantics:
|
||||
|
||||
- Mail can derive host and port from `mail.smtp`, collect missing non-secret
|
||||
transport fields, and bind an existing credential-envelope id. It never
|
||||
accepts or exports a username, password, token, or decrypted credential.
|
||||
- Files compares `files.storage` with the effective runtime backend, endpoint,
|
||||
trust marker, bucket, and presence of referenced environment secrets. Storage
|
||||
remains deployment-owned, so the provider reports `skip` when they agree and
|
||||
blocks drift instead of rewriting process environment or storage credentials.
|
||||
- A system-scoped Mail profile requires system configuration authority. Tenant
|
||||
scope is the conservative default.
|
||||
- Existing Mail configuration is preserved unless a reviewed fragment
|
||||
explicitly selects `on_conflict: update`. Reapplying an unchanged fragment is
|
||||
a no-op.
|
||||
|
||||
Ops projects the same Core-validated receipt. It must not maintain a second
|
||||
parser with different validation or secret-handling rules.
|
||||
|
||||
Core also defines the inverse, read-only dependency-inventory contract used
|
||||
before the installer changes one of those infrastructure capabilities. An
|
||||
enabled module registers
|
||||
`infrastructure.dependency_inventory.<module_id>` and returns bounded, stable
|
||||
references to its persisted configuration or data, a lifecycle state, scope,
|
||||
numeric metrics, and a required operator action. Providers must not return
|
||||
secrets or use this read to migrate state. The Core collector validates provider
|
||||
identity and capability coverage, orders records deterministically, and marks
|
||||
the complete inventory failed when any provider raises or violates the
|
||||
contract. Ops is the authorized projection boundary; the installer remains the
|
||||
consumer and must match installation id, freshness, completion and impacted
|
||||
capability coverage before apply.
|
||||
|
||||
The admin wizard backend starts with these routes:
|
||||
|
||||
@@ -183,6 +235,14 @@ The admin wizard backend starts with these routes:
|
||||
10. Store import provenance, package version, supplied non-secret metadata, and
|
||||
audit events.
|
||||
|
||||
Provider applies may commit independently. Core therefore stops at the first
|
||||
apply or health blocker and reports an explicit rollback state. A blocked
|
||||
preflight or a no-op needs no recovery; a successful multi-provider mutation
|
||||
retains the reviewed pre-apply database snapshot as its generic rollback path;
|
||||
a later-provider failure is reported as a partial apply that requires snapshot
|
||||
recovery or an explicitly supported module-owned compensation. The generic
|
||||
wizard never claims atomic cross-module undo.
|
||||
|
||||
The wizard should display everything necessary and nothing unnecessary. Generic
|
||||
sections should cover package trust, dependency plan, required data, conflicts,
|
||||
review, and result. Module-specific fields should appear only when the selected
|
||||
@@ -233,6 +293,11 @@ Exported packages should record provenance: source GovOPlaN version, module
|
||||
versions, exporter identity, timestamp, selected scope, redactions, and
|
||||
validation status.
|
||||
|
||||
The orchestrator emits this provenance independently of provider payloads and
|
||||
lists secret requirement keys as redacted without serializing their supplied
|
||||
values. Providers still own the deeper rule that credentials, tokens, and
|
||||
decrypted envelope contents must never appear in exported fragments.
|
||||
|
||||
## Catalogs And Trust
|
||||
|
||||
Configuration catalogs should follow the existing module package catalog model:
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Contextual Help Contract
|
||||
|
||||
GovOPlaN exposes context-sensitive help through `F1` and the titlebar help
|
||||
control. The shell resolves a stable help identity from the focused control,
|
||||
its containing surface, and the current route. The Docs module then projects
|
||||
the best visible user or administrator topic for that identity.
|
||||
|
||||
## Resolution Order
|
||||
|
||||
The WebUI resolves help in this order:
|
||||
|
||||
1. an explicit `helpContextId` or `data-help-context-id` on the focused item
|
||||
2. the focused shared control's `interfaceId`, `helpTopicId`, and label key
|
||||
3. a containing dialog, card, administration section, or page surface
|
||||
4. the current registered route, including dynamic module routes
|
||||
5. a stable route-derived fallback when no explicit identity is available
|
||||
|
||||
Focused field and action contexts retain the page context as
|
||||
`fallback_context`. This lets Docs show a field-specific topic when one exists
|
||||
and otherwise open the owning page or module documentation instead of a generic
|
||||
help page.
|
||||
|
||||
## Documentation Lookup
|
||||
|
||||
Static `DocumentationTopic` contributions announce exact contexts through
|
||||
`metadata.help_contexts`. Core publishes that catalogue with the enabled module
|
||||
manifest, allowing the shell to link directly to an exact topic when possible.
|
||||
Docs still performs the authoritative audience, permission, configured-state,
|
||||
and documentation-type filtering.
|
||||
|
||||
Core also maps explicit route, navigation, settings, and View surface IDs to
|
||||
the module's static user or administrator documentation baseline. This makes a
|
||||
page association complete by default and gives every derived field/action
|
||||
context a useful fallback. Exact `metadata.help_contexts` remain the preferred
|
||||
authoring mechanism for consequential or unfamiliar controls.
|
||||
|
||||
When there is no exact topic, Docs resolves the page fallback and then the first
|
||||
visible topic owned by the module. If Docs is unavailable, the shell opens the
|
||||
hosted documentation with the same context parameters.
|
||||
|
||||
## Authoring Controls
|
||||
|
||||
Core shared controls expose stable help metadata. Prefer these props rather
|
||||
than adding custom `F1` listeners:
|
||||
|
||||
- `interfaceId` identifies a durable UI surface or action.
|
||||
- `helpContextId` identifies a documentation context when it differs from the
|
||||
interface identity.
|
||||
- `helpModuleId` identifies the documentation-owning module when a shared
|
||||
control is embedded in another module's page.
|
||||
- `helpTopicId` links directly to a module-owned documentation topic.
|
||||
- translated label keys provide deterministic field identities for ordinary
|
||||
`FormField`, `ToggleSwitch`, search, date/time, email, button, dialog, and card
|
||||
controls.
|
||||
- `TableActionGroup` action definitions carry the same identities so focused
|
||||
row actions can resolve consequence-specific help.
|
||||
- `PageLayout` owns the page help scope and documentation identity for ordinary
|
||||
headed pages. `WorkspaceLayout` owns the full-canvas workspace scope and its
|
||||
labelled primary/content panes; pages inside it use `PageLayout` in
|
||||
`workspace` mode and retain their own route-level help identity.
|
||||
- `PasswordField` passes its owner context and module through reveal/generate
|
||||
actions and the shared generator dialog. Credential consumers must supply an
|
||||
exact owner context; the generic component does not own credential policy.
|
||||
|
||||
High-risk controls use one of the source-inventory risk classes (`authority`,
|
||||
`credential`, `disclosure`, `encryption`, `external-effect`, `irreversible`,
|
||||
`policy`, or `retention`) and require exact F1 help. The extractor infers
|
||||
obvious cases conservatively; components may declare `data-help-risk`
|
||||
explicitly or mark a reviewed ordinary control with
|
||||
`data-help-risk-reviewed="standard"`. The strict workspace gate rejects new
|
||||
unresolved high-risk debt.
|
||||
|
||||
Module routes, public routes, settings sections, and administration sections
|
||||
may also declare `helpContextId` and `helpTopicId`. Each module must keep a
|
||||
static user/admin documentation baseline and should list its important route,
|
||||
workflow, setting, permission, and limitation identities in
|
||||
`metadata.help_contexts`.
|
||||
|
||||
## Boundary
|
||||
|
||||
Help identities describe presentation context; they are not authorization
|
||||
claims. Opening help never bypasses route or documentation permissions. Docs
|
||||
owns documentation projection, feature modules own their content, and Core owns
|
||||
focus capture, context resolution, and fallback routing.
|
||||
@@ -1,8 +1,14 @@
|
||||
# DataGrid Sizing Contract
|
||||
|
||||
Auto-height grids reserve no empty vertical scrollbar gutter. The table fills
|
||||
its card to the right edge; an actual constrained vertical scrollbar still
|
||||
occupies its normal space. `Card bodyLayout="table"` provides an explicit
|
||||
zero-inset surface, including with loading wrappers and padded notices. The
|
||||
Organizations/IDM browser fixtures assert row geometry, not just outer shells.
|
||||
|
||||
`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.
|
||||
layout, container resize, persisted-layout restore, and pointer/keyboard resize.
|
||||
|
||||
## Column Declarations
|
||||
|
||||
@@ -17,6 +23,54 @@ layout, container resize, persisted-layout restore, and pointer resize.
|
||||
- `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.
|
||||
- `columnType: "actions"` marks a custom action/control column. Canonical
|
||||
`TableActionGroup` content is recognized automatically, even in existing
|
||||
column declarations. Use `sticky: "end"` for the normal row-action surface.
|
||||
|
||||
## Action Visibility and Constrained Containers
|
||||
|
||||
Action tracks reserve the width of actual buttons, disabled-action wrappers,
|
||||
reserved empty-state slots, gaps, and cell padding. A historic `width: 72`
|
||||
preference therefore cannot clip a four-button action group. Ordinary data text
|
||||
does not participate in this content measurement; long field values do not
|
||||
silently widen all tracks. Changes to the rendered action set are remeasured.
|
||||
|
||||
`TableActionButton` remains a compact 36 px control, including the Add action
|
||||
in an empty grid. Never stretch it with a last-column `.btn { width: 100% }`
|
||||
rule. Its shared maximum width and fixed flex basis protect against broad
|
||||
consumer button rules, which can otherwise feed stretched widths back into
|
||||
action-track measurement and consume the data area.
|
||||
|
||||
When the full group needs more than half the scroll viewport, its measured
|
||||
minimum is capped at half the viewport and the group wraps. Explicit hard
|
||||
minima remain authoritative. Custom action groups should use wrapping-capable
|
||||
flex layouts and semantic groups, preferably composing `TableActionGroup`.
|
||||
|
||||
The grid's physical width matches its pixel tracks, including horizontal
|
||||
overflow, so right-sticky actions remain inside the correct scroll bounds.
|
||||
If explicitly wide or persisted sticky tracks would obscure the readable data
|
||||
area, horizontal stickiness is released until space returns. No columns or
|
||||
actions are hidden: the labelled scroll region is focusable and supports native
|
||||
keyboard scrolling. Vertical header stickiness remains available.
|
||||
|
||||
## Resizing Controls
|
||||
|
||||
Drag a resize handle with a mouse, pen, or touch pointer. Pointer capture keeps
|
||||
the drag active when it leaves the handle. Escape or pointer cancellation
|
||||
restores the layout before that drag; releasing the pointer commits it. Losing
|
||||
window focus ends a drag without leaving the table stuck in resizing mode.
|
||||
|
||||
Each handle is a focusable vertical separator exposing its current and allowed
|
||||
widths. Left/Right changes its width by 10 px; Shift+Left/Right uses 40 px. Enter
|
||||
or a double-click resets that column's explicit override to the declared sizing
|
||||
rules. Other columns retain their preferences, so cover/compensation constraints
|
||||
still apply. These operations only change personal browser layout, never rows.
|
||||
|
||||
Deutsch: Spalten lassen sich mit Maus, Stift oder Touch ziehen. Escape verwirft
|
||||
den laufenden Ziehvorgang. Am fokussierten Trenner ändern Links/Rechts die Breite
|
||||
um 10 px, mit Umschalt um 40 px. Eingabe oder Doppelklick setzt die persönliche
|
||||
Breite dieser Spalte zurück. Schmale Aktionenspalten umbrechen ihre Schaltflächen;
|
||||
breite Tabellen bleiben horizontal scrollbar.
|
||||
|
||||
## Layout Modes
|
||||
|
||||
@@ -36,16 +90,25 @@ 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.
|
||||
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
|
||||
widths and bounds, sort/filter/resize affordances, column type, sticky placement, initial fit, and resize
|
||||
behavior. A changed signature discards the old override and recomputes the
|
||||
declared layout.
|
||||
|
||||
Container reconciliation is suspended while a pointer drag is active. On
|
||||
release, the already-rendered pixel layout becomes the persisted preference.
|
||||
Reconciliation may grow it to prevent underflow, but never shrinks intentional
|
||||
user overflow, so there is no drag-end snap.
|
||||
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. The current `v3` signature
|
||||
also discards old snapshots that predate action and header-control minima;
|
||||
sort/filter preferences remain intact. Measured action widths are not included
|
||||
in the signature, so changing rows does not erase user sizing intent.
|
||||
|
||||
## Regression Matrix
|
||||
|
||||
@@ -56,6 +119,7 @@ user overflow, so there is no drag-end snap.
|
||||
- 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;
|
||||
@@ -65,3 +129,11 @@ user overflow, so there is no drag-end snap.
|
||||
|
||||
`webui/tests/data-grid-actions.test.tsx` also verifies the rendered fixed-cover
|
||||
shape and guards against reintroducing a synthetic buffer cell.
|
||||
|
||||
`webui/conformance/tests/data-grid-layout.spec.ts` exercises the real rendered
|
||||
grid with deliberately undersized action preferences, constrained containers,
|
||||
horizontal scrolling, changing/empty action sets, keyboard and pointer resizing,
|
||||
Escape cancellation, remount persistence, responsive contraction and restoration,
|
||||
free/content mode, and constrained compensation. Run with
|
||||
`npm run test:conformance -- data-grid-layout.spec.ts`; its isolated test server
|
||||
is stopped automatically afterwards.
|
||||
|
||||
@@ -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
|
||||
|
||||
Worker and queue observability is provider-neutral. Runtime modules register a
|
||||
bounded `RuntimeWorkStatusProviderRegistration` with Core; the Ops module
|
||||
projects its sanitized status without importing Celery, Redis, or module job
|
||||
implementations. Providers must use explicit `null` values for unsupported
|
||||
queue depth, active/reserved work, failure count, and heartbeat evidence. An
|
||||
unavailable metric must never be interpreted as zero or as proof of health.
|
||||
The standard Core adapter reports the configured Celery/Redis runtime and
|
||||
combines its bounded inspection result with registered worker heartbeat and
|
||||
stale-threshold evidence.
|
||||
|
||||
Self-hosted installability follows the staged approach documented in
|
||||
`SELF_HOSTED_INSTALLABILITY.md`: generate an explicit env template, validate it,
|
||||
run production-like rehearsal with Compose-backed dependencies, then use the
|
||||
@@ -57,11 +67,26 @@ PY
|
||||
| `GOVOPLAN_MIGRATION_TRACK` | `release` | Use the release track for normal runtime and deployments. Use `dev` only for fresh/disposable databases that intentionally replay detailed development migrations. |
|
||||
| `DEV_AUTO_MIGRATE_ENABLED` | `true` | Dev convenience only. Production should run migration commands explicitly during deployment. |
|
||||
| `DEV_BOOTSTRAP_ENABLED` | `false` | Dev bootstrap only. `govoplan_core.devserver` and `govoplan/tools/launch/launch-dev.sh` default it to `true`; use controlled first-admin creation outside dev. |
|
||||
| `FIRST_ADMIN_ENROLLMENT_TTL_SECONDS` | `1800` | Lifetime of a locally issued production enrollment credential. Allowed range: 60 seconds to 24 hours. |
|
||||
| `FIRST_ADMIN_ENROLLMENT_FILE` | `/run/govoplan/first-admin-enrollment.json` | Local operator artifact. The command creates it with mode `0600` and never prints the secret. |
|
||||
|
||||
Operator rule: take a database backup before applying migrations or destructive
|
||||
module retirement. For non-SQLite databases, configure deployment-specific
|
||||
backup/restore hooks for the module installer.
|
||||
|
||||
#### Ownership-history upgrade repair
|
||||
|
||||
Core revision `c58a2d7e9f10` repairs existing ownership-transfer tables that
|
||||
predate the `decisions` column. Such installations can otherwise return HTTP
|
||||
500 from `/api/v1/ownership/transfers`, including Campaign Settings. Apply the
|
||||
normal forward migrations after taking a backup; do not stamp a revision or
|
||||
recreate the table. The repair is available on both migration tracks, adds only
|
||||
the missing non-null JSON column, and initializes old rows with an empty list.
|
||||
It preserves owners, approvals, transfer states, revisions, timestamps, and any
|
||||
existing decision history. Historical decisions are not reconstructed or
|
||||
invented. Downgrading this repair retains the additive column and its evidence.
|
||||
Verify that ownership-transfer listing and Campaign Settings load after upgrade.
|
||||
|
||||
### PostgreSQL Production Target
|
||||
|
||||
PostgreSQL is the primary development and production target. SQLite remains
|
||||
@@ -276,12 +301,15 @@ through the same trusted address range.
|
||||
| --- | --- |
|
||||
| `GOVOPLAN_MODULE_PACKAGE_CATALOG_URL` or `GOVOPLAN_MODULE_PACKAGE_CATALOG` | Module package catalog source. |
|
||||
| `GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE` | Preferred production keyring path. |
|
||||
| `GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL` | Approved catalog channel, for example `stable`. |
|
||||
| `GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS` | Comma-separated approved catalog channels, for example `stable`. The legacy singular name remains readable during migration. |
|
||||
| `GOVOPLAN_LICENSE_TRUSTED_KEYS_FILE` | Trusted license issuer keyring path. |
|
||||
| `GOVOPLAN_LICENSE_ENFORCEMENT` | Enables license enforcement when set to `true`. |
|
||||
|
||||
Trust roots are deployment-managed and should not be editable through the
|
||||
running WebUI.
|
||||
running WebUI. When no catalog override is configured, the Admin package
|
||||
directory uses GovOPlaN's public stable catalog and the trust anchor bundled
|
||||
with the installed Core release. Production operators may still pin a newer or
|
||||
institution-specific catalog/keyring explicitly with the settings above.
|
||||
|
||||
### Mail Test Credentials
|
||||
|
||||
@@ -300,8 +328,34 @@ configuration, not the core runtime contract. Store them in a local ignored
|
||||
3. Build the WebUI from `webui/package.release.json` or deploy a prebuilt
|
||||
artifact from the same release tag.
|
||||
4. Run database migrations with the target `DATABASE_URL`.
|
||||
5. Create the first tenant and system owner through the controlled bootstrap or
|
||||
one-time admin command for the deployment.
|
||||
5. Create the first tenant and system owner through the controlled bootstrap:
|
||||
|
||||
```bash
|
||||
python -m govoplan_core.commands.first_admin status
|
||||
python -m govoplan_core.commands.first_admin issue \
|
||||
--reason "initial production installation"
|
||||
```
|
||||
|
||||
The issue command fails when an active system administrator already exists,
|
||||
writes the random credential only to `FIRST_ADMIN_ENROLLMENT_FILE`, and does
|
||||
not print it. Check `GET /api/v1/bootstrap/status`, then submit the account
|
||||
and initial tenant fields to `POST /api/v1/bootstrap/first-admin` with the
|
||||
secret in `X-GovOPlaN-Enrollment-Token`. The operation creates the protected
|
||||
system owner and initial tenant-owner membership in one transaction and
|
||||
retires the credential. A repeated identical request returns the same result
|
||||
without creating another owner.
|
||||
|
||||
If the artifact is lost or expires before use, a local operator may rotate
|
||||
it only while no durable system administrator exists:
|
||||
|
||||
```bash
|
||||
python -m govoplan_core.commands.first_admin recover \
|
||||
--reason "expired installation handoff"
|
||||
```
|
||||
|
||||
Issue and recovery write hash-chained Core evidence and an audit event. They
|
||||
never enable or reuse `DEV_BOOTSTRAP_ENABLED`, `DEV_BOOTSTRAP_PASSWORD`, or
|
||||
`DEV_BOOTSTRAP_API_KEY`.
|
||||
6. Start the API service with `govoplan_core.server.app:app`.
|
||||
7. Start workers when `CELERY_ENABLED=true`.
|
||||
8. Start the WebUI/reverse proxy and verify CORS/cookie settings.
|
||||
@@ -367,6 +421,27 @@ To stop PostgreSQL and Redis when the launcher exits:
|
||||
GOVOPLAN_STOP_PROFILE_DEPENDENCIES_ON_EXIT=1 tools/launch/launch-production-like-dev.sh
|
||||
```
|
||||
|
||||
## Development WebUI Dependency Caches
|
||||
|
||||
The application and browser-conformance harness share installed JavaScript
|
||||
packages but must not share Vite's optimized-dependency cache. The application
|
||||
uses `webui/node_modules/.vite/govoplan-app`; the conformance harness uses
|
||||
`webui/node_modules/.vite/govoplan-conformance`. Keep these explicit sibling
|
||||
directories when adding development or test configurations. Setting a different
|
||||
Vite `root` alone does not isolate this cache.
|
||||
|
||||
A shared cache can make otherwise healthy Workflow, Dataflow or deferred editors
|
||||
show “The resource could not be loaded.” The browser then reports an asset such
|
||||
as `@xyflow_react.js` with HTTP 504 `Outdated Optimize Dep`, while the corresponding
|
||||
API still returns HTTP 200. This is not a missing workflow permission or a reason
|
||||
to rerun a pipeline. Preserve unsaved work, let the existing development server
|
||||
reload the corrected configuration (or restart that WebUI server), then reload
|
||||
the browser. Do not clear application data, change grants or restart delivery
|
||||
workers to repair a frontend dependency cache.
|
||||
|
||||
Run `npm run test:vite-cache-isolation` in `govoplan-core/webui` to verify the real
|
||||
resolved Vite configurations without starting servers or overwriting caches.
|
||||
|
||||
## Module Install/Uninstall Operations
|
||||
|
||||
Use Admin > System > Modules for planning. The running API server validates and
|
||||
@@ -431,6 +506,14 @@ SQLite's backup API; non-SQLite databases require
|
||||
`--database-backup-command`, `--database-restore-check-command`, and
|
||||
`--database-restore-command`.
|
||||
|
||||
Every non-dry run also owns the database-fenced
|
||||
`core:module-lifecycle:deployment` recovery operation. The run record includes
|
||||
its operation id and status. A supervised run reaches durable `succeeded` only
|
||||
after restart and health verification. `recovery_required` or `outcome_unknown`
|
||||
blocks another lifecycle mutation until the recorded operation is reconciled;
|
||||
do not bypass this by deleting `install.lock`. See
|
||||
[`MODULE_LIFECYCLE_RECOVERY.md`](MODULE_LIFECYCLE_RECOVERY.md).
|
||||
|
||||
Database hook commands receive:
|
||||
|
||||
- `GOVOPLAN_INSTALLER_RUN_DIR`
|
||||
|
||||
@@ -17,8 +17,17 @@ operator, and roadmap pages.
|
||||
| Action/effect automation layer | `ACTION_EFFECT_AUTOMATION_LAYER.md` | Action/effect contracts, consequence preview, runner semantics, and module boundary for automation. |
|
||||
| External references and integration maturity | `EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md` | Stable external identity and cumulative connector maturity; configured source authority is defined by the meta target architecture. |
|
||||
| Institutional context and governed references | `INSTITUTIONAL_CONTEXT_CONTRACT.md` | Shared temporal, actor/representation, institution, mandate, service, party, decision, evidence, legal-basis, information-governance, presentation, and geo DTO/provider contracts. |
|
||||
| Provider-neutral record filing | `RECORDS_FILING_CONTRACT.md` | Exact source-revision identity, current source authorization, idempotent filing, capability discovery, and ownership boundary. |
|
||||
| Ticket routing and Case escalation | `TICKET_INTEGRATION_CONTRACTS.md` | Optional fail-open routing, replay-safe Case handoff, authorization, evidence, and ownership boundaries. |
|
||||
| Temporal data read context | `TEMPORAL_DATA_CONTEXT.md` | Valid-time and recorded-time titlebar selection, HTTP/cache contract, security boundary, and module-adoption rule. |
|
||||
| Cross-module information governance adoption | `INFORMATION_GOVERNANCE_ADOPTION.md` | Manifest evidence and enforcement rules for temporal browsing, purpose-aware access, retention, and institutional context. |
|
||||
| Data-subject access and erasure requests | `DATA_SUBJECT_REQUESTS.md` | Provider-owned search and mutation, explicit coverage, governed export, retained evidence, permissions, and idempotent execution. |
|
||||
| Context-sensitive F1 help | `CONTEXTUAL_HELP_CONTRACT.md` | Focus, route, module-manifest documentation contexts, Docs projection, and hosted fallback. |
|
||||
| Semantic documentation subjects | `SEMANTIC_DOCUMENTATION_SUBJECTS.md` | Stable configured-artifact identity, safe provider discovery, revision review, authorization, and lifecycle semantics. |
|
||||
| German localization and help quality gate | `LOCALIZATION_AND_HELP_QUALITY.md` | German reference locale, new-installation default, catalog completeness, automatic page associations, and explicit-help review priorities. |
|
||||
| Postbox E2EE target architecture | `POSTBOX_E2EE_ARCHITECTURE.md` | Strategic encrypted postbox/mailbox model, key ownership, role mailbox semantics, and retraction limits. |
|
||||
| Shared state, runtime coordination, and recovery | `STATE_AND_RECOVERY_CONTRACT.md` | State profiles, object storage, node registration/drain, fenced leases, migration ordering, and recovery evidence. |
|
||||
| Module lifecycle recovery | `MODULE_LIFECYCLE_RECOVERY.md` | Installer/live-graph recovery modes, deployment fence, evidence, retry blocking, and operator reconciliation. |
|
||||
|
||||
## Release And Operations
|
||||
|
||||
@@ -36,8 +45,11 @@ operator, and roadmap pages.
|
||||
| Topic | Canonical document | Notes |
|
||||
| --- | --- | --- |
|
||||
| Product roadmap and module routing | `GOVOPLAN_MASTER_ROADMAP.md` | Product-level sequencing, implementation gates, issue routing, and missing-module decisions. |
|
||||
| Institutional governance target | `govoplan/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md` | Cross-product semantic layers, source-authority modes, candidate Mandates/Services/Parties/Decisions boundaries, and migration sequence. |
|
||||
| Stable platform ideas | `govoplan/docs/strategy/PLATFORM_CORE_IDEAS.md` | Cross-product thesis, canonical distinctions, product experience rule, maturity rule, and decision test. |
|
||||
| Current cross-product reconciliation | `govoplan/docs/strategy/STRATEGY_STATUS.md` | The only current prose status source; generated evidence and Gitea remain authoritative inputs. |
|
||||
| Institutional governance target | `govoplan/docs/architecture/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md` | Cross-product semantic layers, source-authority modes, candidate Mandates/Services/Parties/Decisions boundaries, and migration sequence. |
|
||||
| UI/UX decisions | `UI_UX_DECISION_LEDGER.md` | Binding guided-UI decisions, open decisions, impact index, and review checklist. |
|
||||
| Core interface migration | `INTERFACE_PATTERN_MIGRATION.md` | Core-owned settings, credential, retention, lifecycle, and shared-component evidence for the product pattern language. |
|
||||
| Interface ethics and design doctrine | `INTERFACE_ETHICS_AND_DESIGN_DOCTRINE.md` | Product-level doctrine for context, decision, consequence, contestability, responsibility, and traceability. |
|
||||
| Public-sector integration posture | `PUBLIC_SECTOR_INTEGRATION_STRATEGY.md` | Strategy index; executable target inventory lives in `govoplan-connectors`. |
|
||||
| Configuration packages | `CONFIGURATION_PACKAGES.md` | Package model, provider contract, import/export flow, and tracking slices. |
|
||||
|
||||
@@ -11,6 +11,9 @@ 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
|
||||
@@ -23,3 +26,19 @@ 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.
|
||||
|
||||
@@ -40,7 +40,7 @@ must not imply that GovOPlaN holds an authoritative copy.
|
||||
Integration maturity states what an adapter is capable of doing. It does not
|
||||
decide which system owns truth for a configured object or field group. A
|
||||
binding separately selects one of the source-authority modes defined by the
|
||||
[institutional governance target architecture](../../govoplan/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md):
|
||||
[institutional governance target architecture](../../govoplan/docs/architecture/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md):
|
||||
|
||||
- `native_authoritative`
|
||||
- `external_authoritative`
|
||||
|
||||
@@ -13,4 +13,4 @@ tools/gitea/gitea-sync-wiki.py --help
|
||||
|
||||
Canonical documentation:
|
||||
|
||||
- `/mnt/DATA/git/govoplan/docs/GITEA_ISSUES.md`
|
||||
- `/mnt/DATA/git/govoplan/docs/project/GITEA_ISSUES.md`
|
||||
|
||||
@@ -221,7 +221,8 @@ Admin lists use bounded container grids:
|
||||
- recipient import with column mapping;
|
||||
- session/device revocation UI;
|
||||
- backup/restore, monitoring, and update procedures;
|
||||
- DSAR workflows and evidence bundle verifier;
|
||||
- additional module providers and signed human-readable response packages for
|
||||
the implemented DSAR workflow described in `DATA_SUBJECT_REQUESTS.md`;
|
||||
- campaign ownership transfer workflow;
|
||||
- policy impact analysis before delete/disable/unshare/change;
|
||||
- LDAP/OIDC/SAML provisioning;
|
||||
|
||||
@@ -10,15 +10,15 @@ gates. Issues are the active backlog; this document is durable architecture
|
||||
planning context and should be mirrored to the Gitea wiki.
|
||||
|
||||
The meta repository's
|
||||
[Connected Governance Platform Roadmap](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
|
||||
[GovOPlaN Roadmap](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/strategy/ROADMAP.md)
|
||||
describes the corresponding cross-product stakeholder visions, configurable
|
||||
service and operating configurations, connected outcome stories, and
|
||||
capability horizons. The selected five-stage delivery sequence and its gates
|
||||
are in the meta repository's
|
||||
[Reference Journey Program](https://git.add-ideas.de/GovOPlaN/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/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
|
||||
[Institutional Governance Target Architecture](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/architecture/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
|
||||
Those product documents are canonical; this Core roadmap remains their
|
||||
technical sequencing and module-routing companion.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -8,6 +8,9 @@ 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
|
||||
|
||||
|
||||
Executable
+101
@@ -0,0 +1,101 @@
|
||||
# Integrity-preserving performance contracts
|
||||
|
||||
These are implementation guarantees and regression-test boundaries, not a
|
||||
security certification or production load-test result. Feature-specific policies
|
||||
and help remain in the owning modules' English and German documentation topics.
|
||||
|
||||
## Refreshes, edits and table rendering
|
||||
|
||||
The shell runs at most one module-load refresh per authority generation. Multiple
|
||||
invalidations coalesce into one trailing refresh; stale results and errors cannot
|
||||
replace a newer generation. Focus refreshes are throttled to five seconds and
|
||||
focus/visibility refreshes on hidden pages are suppressed; explicit module
|
||||
invalidations still trigger a read. Authentication/tenant reset disposes the
|
||||
previous controller. Authentication and authorization checks are not cached away.
|
||||
|
||||
Editable modules must reconcile a save against the submitted draft and accepted
|
||||
server revision: edits made while a request is pending remain dirty. Completion
|
||||
must be fenced by security-relevant authority and selection, not merely an auth
|
||||
object's reference identity. A harmless profile refresh must not discard an
|
||||
accepted newly created ID and invite a duplicate create. An old account's mutation
|
||||
continuation must not reload its catalogue into the current account's page.
|
||||
|
||||
DataGrid precomputes first-occurrence row indices once for client sorting and
|
||||
filtering. Duplicate object references, primitive values, `NaN` and sparse arrays
|
||||
retain `Array.indexOf` behavior. Comparator order, visible pagination, server-side
|
||||
pagination, sizing and resize rules are unchanged. Do not replace this with a
|
||||
last-occurrence map or change ordering as a side effect of an optimization.
|
||||
|
||||
## Conditional responses
|
||||
|
||||
The shared JSON GET middleware performs route handling, including authorization,
|
||||
before considering `If-None-Match`. It buffers only responses up to 1 MiB for a
|
||||
body-derived ETag. Known larger responses bypass buffering; an unknown-length
|
||||
stream crossing that limit replays its exact prefix and streams the remainder.
|
||||
No data is truncated and no large joined copy is created. The crossing chunk is
|
||||
already producer-owned: this is not a process-wide or route-output memory limit.
|
||||
Empty chunks do not accumulate. Large responses may no longer receive a
|
||||
middleware-generated ETag/304; explicit route ETags remain intact. Small-response
|
||||
cache semantics and credential/language/context `Vary` fields are retained.
|
||||
|
||||
## Shared helpers and concurrency
|
||||
|
||||
Central helpers replace exact live-code duplicates only. Actor precedence,
|
||||
whitespace handling and service-account differences remain explicit owner choices;
|
||||
historical migration code is not redirected to mutable runtime helpers. Connector
|
||||
search ACL token projection keeps its first-seen ordering and existing 500-token
|
||||
cap, stopping work once that cap is reached. Provider schemas are inferred in one
|
||||
pass without storing a second list of every column value.
|
||||
|
||||
The keyed-list three-way merge retains insertion anchors when unrelated fields
|
||||
change. Concurrent additions use deterministic ordering; contradictory anchors
|
||||
produce a collection-order conflict instead of silently relocating an item.
|
||||
No existing endpoint is newly opted into merge behavior by this change.
|
||||
|
||||
SQL JSON authorization predicates support the explicitly tested SQLite and
|
||||
PostgreSQL dialects, retain exact string membership and reject unsupported
|
||||
dialects. Apply tenant and authorization predicates before counting/pagination;
|
||||
never page a broader result first and filter away unauthorized records afterward.
|
||||
SQLite execution and PostgreSQL SQL compilation are not substitutes for a
|
||||
deployment's PostgreSQL concurrency and representative-data load tests.
|
||||
|
||||
## Migration connection URLs
|
||||
|
||||
Alembic preserves the configured database URL exactly in online and offline
|
||||
migration modes, including percent-encoded credentials and PostgreSQL Unix-socket
|
||||
paths. Escaping applies only at its ConfigParser boundary; operators must not
|
||||
double-escape `%` in `DATABASE_URL` or alter working connection credentials to
|
||||
work around interpolation errors. This does not change the target database,
|
||||
authentication, TLS policy, or migration contents.
|
||||
|
||||
## Deutsch: Integrität vor Geschwindigkeit
|
||||
|
||||
Der zentrale Modul-Refresh bündelt gleichzeitige Auslöser und verwirft veraltete
|
||||
Ergebnisse einschließlich Fehlermeldungen. Ein Wechsel von Anmeldung oder Mandant
|
||||
beendet die bisherige Generation. Fokusaktualisierungen sind auf einen Auslöser
|
||||
je fünf Sekunden begrenzt. Berechtigungsprüfungen bleiben erhalten.
|
||||
|
||||
Speicherantworten dürfen zwischenzeitliche Bearbeitungen nicht überschreiben.
|
||||
Ein unveränderter Berechtigungskontext mit einem neuen Profilobjekt darf eine
|
||||
bereits bestätigte neue ID oder Revision nicht verwerfen. Umgekehrt dürfen alte
|
||||
Anfragen nach einem Kontowechsel keine Daten in den neuen Kontext übernehmen.
|
||||
Die DataGrid-Optimierung erhält Reihenfolge, Filter-, Seiten- und Größenverhalten.
|
||||
|
||||
Die ETag-Middleware puffert höchstens 1 MiB Nutzdaten zuzüglich eines bereits vom
|
||||
Erzeuger gelieferten Grenz-Chunks. Größere Antworten werden vollständig weitergereicht,
|
||||
nicht abgeschnitten; automatisch erzeugte ETags können dabei entfallen.
|
||||
Autorisierung läuft auch bei bedingten Anfragen. Das ist keine allgemeine
|
||||
Speicherbegrenzung für Routen oder Prozesse.
|
||||
|
||||
Gemeinsame Helfer erhalten die bisherigen fachlichen Unterschiede. Listen-Merges
|
||||
bewahren Einfügepositionen oder melden widersprüchliche Reihenfolgen explizit als
|
||||
Konflikt. Datenbankseitige Autorisierung erfolgt vor Zählung und Seitenauswahl.
|
||||
Regressionstests belegen diese Verträge; reale Provider-, PostgreSQL- und Lasttests
|
||||
in einer repräsentativen Umgebung bleiben Teil der Betriebsfreigabe.
|
||||
|
||||
Alembic übernimmt die konfigurierte Datenbank-URL in Online- und Offline-Läufen
|
||||
unverändert, einschließlich prozentkodierter Zugangsdaten und PostgreSQL-
|
||||
Unix-Socket-Pfade. Die Maskierung erfolgt ausschließlich an der ConfigParser-
|
||||
Grenze; `%` in `DATABASE_URL` nicht doppelt maskieren und funktionierende
|
||||
Zugangsdaten nicht als Umgehung ändern. Zieldatenbank, Anmeldung, TLS-Vorgaben und
|
||||
Migrationsinhalte bleiben unverändert.
|
||||
@@ -0,0 +1,90 @@
|
||||
# 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 guarded Reload in the right-aligned group immediately before Create/primary actions; 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 |
|
||||
| Measured operation feedback | `LoadingFrame` over existing content | Use `indicator="none"` with native measured progress for long-running operations; `progress={null}` means unknown, never a synthetic percentage. Keep dialog content inert and close controls disabled until success or error. Existing consumers retain their loading indicator. | Files archive inspection/extraction, layout primitive tests, managed-archive browser conformance |
|
||||
|
||||
## Boundary
|
||||
|
||||
Side-rail customization uses the shared `NavigationPreferenceEditor` for system,
|
||||
tenant, personal, and View layouts. Modules and labelled separators share one
|
||||
ordered list, with pointer drag-and-drop, keyboard reordering, and explicit
|
||||
add/remove actions. Consumers retain persistence and dirty-state ownership;
|
||||
mounting the editor does not create a draft change. See
|
||||
`NAVIGATION_LAYOUT_CONTRACT.md` for inheritance, locked items, optional-module
|
||||
preservation, and collapsed-rail grouping.
|
||||
|
||||
Action columns use `TableActionGroup` or declare `columnType: "actions"` when
|
||||
their composition differs. DataGrid owns measured action minima, initial column
|
||||
allocation, persistent resizing, and local horizontal scrolling; consumers must
|
||||
not compensate with clipped overflow or copied fixed widths. See
|
||||
`DATAGRID_SIZING_CONTRACT.md`. Dialog forms use `DialogForm` and `FormGrid` inside
|
||||
the shared size-bounded dialog. Do not add a content minimum wider than the
|
||||
panel's padded interior. Genuinely wide content, such as a table, owns its own
|
||||
local scroller instead of making the entire dialog scroll horizontally.
|
||||
|
||||
In `FormGrid` and `FormLayout`, direct `FormField` and `ToggleSwitch` items
|
||||
align their controls at the row's lower edge. A single control inside a
|
||||
`GridItem` follows the same rule. Labels may wrap without shifting adjacent
|
||||
switches up into the label row. Do not add per-module top margins or empty
|
||||
labels; single-column layouts must not retain a phantom label spacer.
|
||||
|
||||
Credential editors resolve public reference labels when opened. A failed
|
||||
save displays its error inside the dialog and keeps the entered draft for an
|
||||
explicit retry. While a write is pending, repeated submission, edits, and
|
||||
dialog dismissal are disabled; no configured secret is read back from storage.
|
||||
|
||||
The shared rich-text editor emits content changes only for actual document
|
||||
edits. Mounting, read-only changes, loading a saved value, and switching between
|
||||
visual and source inspection must preserve the controlled HTML without marking
|
||||
the owning page dirty. This is especially important for legacy Campaign HTML:
|
||||
merely visiting Template must not normalize it or require a save on leaving.
|
||||
The WYSIWYG lifecycle browser conformance covers both visual and legacy-source
|
||||
initial content, as well as genuine typing.
|
||||
|
||||
Files and Mail are the first two external consumers of the layered
|
||||
server/credential/policy pattern. Their own repositories retain provider
|
||||
discovery, transport behavior, authorization, and migration evidence. Remaining
|
||||
module surfaces are tracked by bounded module-owned issues under GovOPlaN #11;
|
||||
they are not reasons to add sibling-private behavior to Core.
|
||||
|
||||
Raw JSON remains permitted only for diagnostics, expert inspection,
|
||||
interchange, or conflict evidence. It is not a primary Core configuration
|
||||
editor.
|
||||
|
||||
New headed pages use `PageLayout`; full-canvas modules use `WorkspaceFrame`
|
||||
and, where applicable, `WorkspaceLayout`, so Core owns the frame and pane
|
||||
scrolling. Module CSS continues to own unequal domain content layout, never the
|
||||
shared page, workspace, toolbar, state, list, filter, metric, section, or graph
|
||||
chrome. Retired copies and module-local component definitions are rejected by
|
||||
`check-shared-webui-primitives.py`. That check also requires standard dialog
|
||||
widths to use `Dialog size` and keeps every remaining domain-specific width in
|
||||
a reviewed, decrease-only exception baseline. The companion layout check now
|
||||
has zero raw page-frame and zero raw workspace exceptions, discovers semantic
|
||||
consumers without a hand-maintained route list, requires semantic action bars
|
||||
on `WorkspaceFrame` routes, and rejects ad-hoc panel-header toolbars.
|
||||
@@ -0,0 +1,139 @@
|
||||
# Localization And Contextual Help Quality
|
||||
|
||||
## Reference Language
|
||||
|
||||
German (`de`) is GovOPlaN's first-class reference target. Every translation key
|
||||
used by a shipped WebUI must exist in German and English. German completeness is
|
||||
a release gate; English remains the source-code fallback language so existing
|
||||
literal labels and external developer APIs do not change semantics.
|
||||
|
||||
New installations and tenants default to German. Existing system, tenant, and
|
||||
user preferences are preserved. The available-language and policy model can
|
||||
still select another default or disable a package at the relevant scope.
|
||||
|
||||
Explicit high-risk help content and browser acceptance are tracked in
|
||||
[Core #284](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/284).
|
||||
|
||||
The platform inventory recognizes both inline locale objects and generated
|
||||
catalogs declared as `const de` / `const en`. Its strict mode requires both
|
||||
locales and reports `de` explicitly as the reference locale.
|
||||
|
||||
## Structured Documentation Localization
|
||||
|
||||
`DocumentationTopic.translations` continues to own localized title, summary,
|
||||
and body prose. Topics whose metadata contains rendered prose opt into the
|
||||
separate `structured_translation_version="1"` contract and provide a complete
|
||||
same-shape value for each translated metadata key in
|
||||
`structured_translations`. Version 1 covers workflow prerequisites, steps,
|
||||
outcome, result and verification; reference fields; limitations, constraints,
|
||||
consequences and consequence classes; and the other rendered explanation
|
||||
fields declared by Core.
|
||||
|
||||
The registry rejects an unversioned translation, an unsupported contract
|
||||
version, missing structured keys, changed object keys or list lengths, empty
|
||||
translated strings, and changed non-text values. Stable field IDs, routes,
|
||||
permission scopes, and other technical leaves therefore remain structurally
|
||||
bound to the source metadata. The Docs module overlays only a validated locale
|
||||
at response time and reports the selected structured locale separately from the
|
||||
title/body locale. Missing structured translations fall back to source content
|
||||
and remain visible in public coverage until the owning module adopts the
|
||||
contract.
|
||||
|
||||
## Help Resolution
|
||||
|
||||
Every focusable field and action receives a stable derived F1 identity from the
|
||||
shared shell, even when the component has no dedicated help text. Resolution
|
||||
falls back from field/action to dialog or page and then to the module's visible
|
||||
documentation baseline.
|
||||
|
||||
Backend manifests publish explicit topic associations first. Core additionally
|
||||
associates declared route, navigation, settings, and View surface IDs with the
|
||||
module's static user or administrator documentation baseline. Feature modules
|
||||
should still add exact `metadata.help_contexts` entries for consequential,
|
||||
unfamiliar, policy-controlled, destructive, security-sensitive, or legally
|
||||
meaningful fields and actions.
|
||||
|
||||
The shared retention-policy editor exposes explicit contexts for each stored
|
||||
data category, audit-detail control, lower-level override switch, target
|
||||
selector, reload, and save action. The Policy module owns the matching German
|
||||
administrator guidance. Retention execution surfaces use separate contexts for
|
||||
dry-run, destructive apply, confirmation, and outcome review so F1 opens the
|
||||
consequence and recovery guidance closest to the focused control.
|
||||
Shared controls may set `helpModuleId` when their documentation owner differs
|
||||
from the containing page; the retention editor uses this to resolve Policy help
|
||||
from both administration and Campaign surfaces.
|
||||
|
||||
The shared reusable-credential manager keeps Access as its documentation owner
|
||||
and publishes exact contexts for credential kind, secret replacement/removal,
|
||||
module and server restrictions, lower-scope visibility, activation, save, and
|
||||
irreversible deletion. This ensures F1 explains secret custody and the effect on
|
||||
dependent connections from system, tenant, group, user, and personal surfaces.
|
||||
|
||||
The source inventory treats literal `helpContextId` and
|
||||
`data-help-context-id` declarations as authored help associations, including a
|
||||
native control nested in `FormField`. Dynamic context expressions remain
|
||||
separate evidence and generic derived fallbacks remain in the richer-help
|
||||
candidate queue.
|
||||
|
||||
The same inventory classifies controls whose labels, identities, component
|
||||
context, or explicit `data-help-risk` indicate authority, credentials,
|
||||
disclosure, encryption, external effects, irreversible changes, policy, or
|
||||
retention. These controls require an exact context rather than relying only on
|
||||
page fallback. Reviewed false positives carry
|
||||
`data-help-risk-reviewed="standard"`. Invalid risk classes and any increase
|
||||
above the versioned `tools/inventory/high-risk-help-baseline.json` ceiling fail
|
||||
strict declaration checks; the ceiling is lowered as the finite queue is
|
||||
resolved. Password fields and their generator dialog propagate the owning
|
||||
field's context so shared credential controls never invent a Core-owned topic.
|
||||
|
||||
The generated `help_review_candidates` list is therefore a content-depth queue,
|
||||
not a list of controls on which F1 cannot work. It should prioritize:
|
||||
|
||||
1. effect, deletion, delivery, retention, disclosure, encryption, and recovery;
|
||||
2. identity, representation, mandate, institutional context, and purpose;
|
||||
3. valid-time versus recorded-time selection;
|
||||
4. provider authority, synchronization, conflict, and outcome unknown;
|
||||
5. fields whose consequences are not evident from their label.
|
||||
|
||||
The shared browser conformance journey mounts the production Help menu and
|
||||
resolver. It proves that F1 uses the focused control rather than only the page,
|
||||
maps an exact retention action to Policy-owned administrator documentation,
|
||||
retains the page context as fallback for derived actions, exposes an accessible
|
||||
modal at narrow widths, closes with Escape, and restores focus to the triggering
|
||||
control. Module journeys should add their own exact high-risk mappings; they do
|
||||
not need to reimplement the keyboard or dialog mechanics.
|
||||
|
||||
The same conformance suite mounts the production Forms Runtime self-service and
|
||||
assisted Anwohnerparkausweis surfaces with German module translations. Desktop
|
||||
and mobile runs traverse native controls by keyboard, inspect accessible names
|
||||
and landmarks, run WCAG 2.1 A/AA automation, verify responsive overflow, and
|
||||
retain independent per-field assisted provenance. Physical assistive-technology
|
||||
spot checks remain release evidence rather than being represented as browser
|
||||
automation.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan
|
||||
/mnt/DATA/git/govoplan/.venv/bin/python \
|
||||
tools/inventory/platform-interface-inventory.py \
|
||||
--strict --strict-declarations --strict-endpoints
|
||||
```
|
||||
|
||||
The check must report:
|
||||
|
||||
- reference locale `de` present and complete;
|
||||
- no used key missing from `de` or `en`;
|
||||
- every field has a resolvable F1 context;
|
||||
- no duplicate stable IDs;
|
||||
- no undeclared public WebUI surface;
|
||||
- no stale runtime route or endpoint declaration.
|
||||
- no invalid high-risk help annotation or regression above the recorded
|
||||
exact-context debt ceiling.
|
||||
|
||||
Browser acceptance is part of the focused workspace gate and can be run alone:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core/webui
|
||||
npm run test:conformance
|
||||
```
|
||||
+194
-3
@@ -16,7 +16,7 @@ The experimental remote WebUI bundle loading design is tracked in
|
||||
The cross-product semantic layers, source-authority modes, and candidate
|
||||
Mandates, Services, Parties, and Decisions boundaries are canonical in the
|
||||
meta repository's
|
||||
[`INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md`](../../govoplan/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
|
||||
[`INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md`](../../govoplan/docs/architecture/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
|
||||
|
||||
## Layer Model
|
||||
|
||||
@@ -128,6 +128,8 @@ The following contracts are the baseline API that modules can rely on:
|
||||
- bounded reference-option search provider contract
|
||||
- single-tenant and optional batched tenant summary provider contracts
|
||||
- tenant delete-veto provider contract
|
||||
- provider-neutral tenant-erasure preview, step, idempotency, and
|
||||
reconciliation contracts in `govoplan_core.core.tenant_erasure`
|
||||
- WebUI module contribution contract
|
||||
- navigation metadata contract
|
||||
- command/event envelope contract
|
||||
@@ -149,6 +151,17 @@ 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.
|
||||
|
||||
Governed populated-tenant erasure is separate from ordinary delete vetoes.
|
||||
Modules contribute `tenancy.erasure_provider.<module_id>` capabilities with a
|
||||
bounded resource inventory, explicit erase/retain/legal-hold/external/key/
|
||||
backup dispositions, ordered destructive warnings, idempotent step execution,
|
||||
and reconciliation. The collector fails closed when a provider is invalid or
|
||||
fails. A module with nonzero tenant summary counts and no erasure capability is
|
||||
reported as unsupported and blocks execution; modules with neither contract
|
||||
are explicitly projected as outside tenant-persistence scope. Provider
|
||||
evidence contains counts and stable references only and must never contain
|
||||
secrets or erased subject data.
|
||||
|
||||
This list is the Milestone A kernel-contract freeze baseline. New module work
|
||||
may extend the kernel by adding explicit contracts, but existing contracts must
|
||||
remain source-compatible through the 0.1.x split line unless a migration shim
|
||||
@@ -213,8 +226,29 @@ Other stable runtime capabilities currently include:
|
||||
`calendar.externalProfiles`
|
||||
- `poll.scheduling`
|
||||
- `notifications.dispatch`
|
||||
- `application_status.projection`
|
||||
- `payments.requests`
|
||||
- `workflow.definitionContributions` and `workflow.runtimeWorker`
|
||||
|
||||
`calendar.scheduling` keeps workflow modules independent of Calendar-owned
|
||||
models and transport adapters. Consumers may create tentative events, promote
|
||||
the selected event in place, and release unused events idempotently. The
|
||||
provider returns bounded external-delivery and outbox references so consumers
|
||||
can retain retry state without copying Calendar's synchronization internals.
|
||||
|
||||
`application_status.projection` lets a presentation module resolve the tenant
|
||||
and display or request access to an owner-supplied, deliberately bounded
|
||||
applicant-status view. The provider retains policy, authorization, token, and
|
||||
record ownership; consumers must not query provider tables or enlarge the
|
||||
projection.
|
||||
|
||||
`payments.requests` carries replay-safe payment obligations and evidence-bound
|
||||
manual reconciliation across module boundaries. Procedure modules identify the
|
||||
source Case or Workflow in the command and retain the returned payment ID;
|
||||
Payments remains authoritative for amount, currency, state, transaction
|
||||
reference, and reconciliation evidence. Ledger, invoice, and external payment
|
||||
providers remain separate follow-on contracts.
|
||||
|
||||
The provider-neutral `idm.relationships` contract carries tenant-scoped typed
|
||||
groups, effective-dated identity relationships, and explicit membership
|
||||
decisions. It deliberately does not expose IDM persistence models or imply an
|
||||
@@ -305,6 +339,8 @@ contract checks, are:
|
||||
- `files.access`, `files.campaign_attachments`
|
||||
- `mail.campaign_delivery`
|
||||
- `notifications.dispatch`
|
||||
- `application_status.projection`
|
||||
- `payments.requests`
|
||||
- `poll.availability_matrix`, `poll.option_selection`,
|
||||
`poll.response_collection`, `poll.signed_participation`,
|
||||
`poll.workflow_context`
|
||||
@@ -738,6 +774,13 @@ effects, transitions partial/unknown outcomes honestly, and records verified
|
||||
completion or recovery. Plaintext secrets must never enter recovery metadata or
|
||||
evidence.
|
||||
|
||||
For a conclusive external result, modules may commit their local success
|
||||
projection and the verified terminal checkpoint in one database transaction via
|
||||
`DurableRecoveryOperation.commit_verified_success`. This does not make the
|
||||
external provider effect atomic. It prevents a local `succeeded` state from
|
||||
becoming authoritative when the recovery evidence chain is damaged or the
|
||||
terminal checkpoint cannot commit.
|
||||
|
||||
## Install, Uninstall, And Catalogs
|
||||
|
||||
Core owns the install plan, signed catalog validation, license entitlement
|
||||
@@ -827,6 +870,24 @@ the shared loading and retryable error state around route rendering. The
|
||||
initial static import closure and largest asynchronous chunk are enforced by
|
||||
the budgets documented in [WEBUI_BUNDLE_BUDGETS.md](WEBUI_BUNDLE_BUDGETS.md).
|
||||
|
||||
Every public platform interface has a stable declaration identity. Backend
|
||||
routes, capabilities, interfaces, search providers/sources, permissions,
|
||||
frontend routes/navigation, and View surfaces derive that identity from typed
|
||||
`ModuleManifest` values. Typed WebUI capabilities declare IDs for settings,
|
||||
admin sections, widgets, search contexts, and extension actions. Shared form
|
||||
and action controls accept `interfaceId` and `helpTopicId`; use module-namespaced
|
||||
values when another contract, documentation topic, or automated check must
|
||||
refer to the control across source changes. The static inventory assigns a
|
||||
line-independent source anchor when an explicit ID is absent and reports that
|
||||
fact for later review.
|
||||
|
||||
Core exposes the sanitized runtime declaration set at
|
||||
`GET /api/v1/platform/interface-catalog`. The endpoint is read-only, requires
|
||||
`admin:module:read` or `system:settings:read`, and includes only modules
|
||||
effective in the caller's active tenant context. It never serializes factories,
|
||||
credentials, executable callbacks, or mutable module state. Registry validation
|
||||
rejects conflicting declaration IDs before startup.
|
||||
|
||||
WebUI modules receive only the core route context:
|
||||
|
||||
- `settings`
|
||||
@@ -982,6 +1043,49 @@ Any future exception is extraction debt and must be temporary, documented in the
|
||||
script with a reason, and removed when a capability/API/event contract replaces
|
||||
it.
|
||||
|
||||
## Product Surface Contributions
|
||||
|
||||
`FrontendModule.product_surfaces` is the versioned product-composition contract
|
||||
for stable identities that may have one or more technical owners. A contribution
|
||||
declares contract version 1, a product identity, common label/icon/description,
|
||||
stable entry path, owner route and View surfaces, supported task/reader/admin/
|
||||
operator presentations, authorization requirements, capabilities, search
|
||||
sources, help contexts, documentation topics, migration aliases, and standard
|
||||
unavailable/degraded explanations.
|
||||
|
||||
Core validates every reference against the owning manifest. Contributors that
|
||||
share an identity must agree on its common product metadata and entry path;
|
||||
entry and alias paths cannot belong to another product identity. The WebUI
|
||||
composes valid owners by product id, filters them through authorization and the
|
||||
effective View, and resolves the stable entry or migration alias to the first
|
||||
available owner route. It emits `govoplan:product-surface-route-resolved` before
|
||||
the redirect so migration telemetry can observe alias use without making the
|
||||
technical module part of the ordinary label.
|
||||
|
||||
The shell projects every authorized, View-visible owner route with a product
|
||||
contribution into one stable product navigation item. The product label and
|
||||
entry path replace package topology in the primary rail; every contributing
|
||||
owner path still marks that item active. `All available tools` is a collapsed,
|
||||
permission-derived catalogue built independently of the active View, so a
|
||||
focused workflow cannot remove the explicit escape. It may reveal an
|
||||
authorized owner route that a View omitted, but never an unauthorized route.
|
||||
Navigation visibility preferences do not delete catalogue entries, and the
|
||||
original owner routes remain compatible deep links.
|
||||
|
||||
The initial promoted destinations are `work.items` at `/work`,
|
||||
`meetings.calendar` at `/agenda`, `communication.messages` at `/messages`
|
||||
(with `/inbox` as an alias), and `records.files` at `/documents`. Their labels
|
||||
and availability language are centralized in Core while Tasks, Calendar,
|
||||
Mail/Postbox, and Files retain route, command, search, help, documentation,
|
||||
authorization, and data ownership.
|
||||
|
||||
Use `ProductAvailabilityState` for unavailable and degraded outcomes. The
|
||||
ordinary state explains the attempted outcome, consequence, recovery path and
|
||||
responsible role. Exact module, capability, provider and correlation values may
|
||||
be supplied as a collapsed technical detail; they are not the primary error.
|
||||
The state is presentation only and never grants authority or changes provider
|
||||
health.
|
||||
|
||||
## Boundary Decision Register
|
||||
|
||||
These durable decisions close older exploratory core issues. Implementation
|
||||
@@ -1232,6 +1336,61 @@ devserver, development bootstrap, background worker registry, and migration
|
||||
metadata plan all read the saved desired state from `system_settings` before
|
||||
building their module registry.
|
||||
|
||||
### Tenant entitlement and personal visibility
|
||||
|
||||
Deployment activation remains process-wide: one installed and active registry
|
||||
is shared by every tenant served by that process. Tenant module selection is a
|
||||
separate entitlement document in `core_scopes.settings.module_entitlements`:
|
||||
|
||||
- a system policy marks each installed module `unavailable`, `available`, or
|
||||
`forced` for one tenant;
|
||||
- the tenant selection may enable or disable only available modules;
|
||||
- protected platform modules, forced modules, and transitive dependencies stay
|
||||
effective;
|
||||
- malformed explicit entitlement fails closed to protected modules, while an
|
||||
absent document preserves the pre-entitlement behavior for upgraded tenants;
|
||||
- an optimistic revision prevents concurrent system and tenant administrators
|
||||
from silently replacing each other's changes.
|
||||
|
||||
The authenticated platform metadata and module route guard intersect global
|
||||
runtime activation with the active tenant's effective entitlement. Entitlement
|
||||
does not grant a permission. Access authorization must still allow every API
|
||||
operation and resource.
|
||||
|
||||
The same boundary applies outside authenticated request handling:
|
||||
|
||||
- capability factories retain their owning module, and tenant-scoped capability
|
||||
lookup treats a provider that is unavailable to the tenant as absent;
|
||||
- workers partition scheduled scans by tenant before claiming rows;
|
||||
- new work is rejected while a module is unavailable, while already accepted
|
||||
durable work remains in provider-owned storage and is reported as
|
||||
`operator_action_required` instead of being dropped or executed;
|
||||
- Workflow, Dataflow, event consumers, reconciliation jobs, and external-effect
|
||||
outboxes run inside a tenant execution context, so their optional capability
|
||||
calls inherit the same provider checks;
|
||||
- public signed-link modules declare a `public_tenant_resolver`; valid token
|
||||
context is resolved before the route runs and the module entitlement is then
|
||||
enforced without requiring an authenticated principal.
|
||||
|
||||
Entitlement resolution uses a bounded process-local cache. A local policy
|
||||
mutation invalidates its tenant entry immediately; changes made by another node
|
||||
become authoritative after `TENANT_MODULE_ENTITLEMENT_CACHE_TTL_SECONDS`
|
||||
(five seconds by default). This is a bounded staleness optimization, not an
|
||||
authorization grant: a cache miss or resolution failure fails closed.
|
||||
|
||||
Users and groups do not own another module-runtime state. Every WebUI module
|
||||
already contributes a root `<module>.module` View surface, so personal and
|
||||
group module visibility is expressed through Views. View policy controls who
|
||||
may select, assign, edit, derive, or workflow-activate those projections;
|
||||
required View assignments can retain required UI. Thus tenant entitlement owns
|
||||
operational availability, Views own presentation, and Access owns authority.
|
||||
|
||||
Capability-style modules such as Encryption must keep activation separate from
|
||||
domain data state. Making Encryption effective only exposes its capability and
|
||||
administration surfaces. Encrypting, rekeying, decrypting, or migrating data is
|
||||
an explicit versioned protection-policy operation owned by Encryption and the
|
||||
module that owns the data.
|
||||
|
||||
Hot enable/disable is a core design principle for every module:
|
||||
|
||||
- Core keeps one mutable active `PlatformRegistry` object and swaps its manifest
|
||||
@@ -1297,8 +1456,10 @@ The package install-plan API records operator intent only:
|
||||
- `GET /api/v1/admin/system/modules/package-catalog` reads approved package
|
||||
references from `GOVOPLAN_MODULE_PACKAGE_CATALOG` so operators can add known
|
||||
module refs to the install plan without typing them manually. The endpoint
|
||||
also reports catalog validity, channel, signature, trust state, and the
|
||||
configured path.
|
||||
also reports catalog validity, channel, signature, trust state, source and
|
||||
artifact provenance, release availability, configuration requirements, and
|
||||
per-entry compatibility/blocker state. Withdrawn entries are visible for
|
||||
diagnosis but cannot be planned.
|
||||
- `POST /api/v1/admin/system/modules/install-plan/catalog/{module_id}` saves
|
||||
a planned install or update row from a validated catalog entry. Installed
|
||||
modules are planned as updates. Catalog signature and approved-channel policy
|
||||
@@ -1339,6 +1500,11 @@ The package install-plan API records operator intent only:
|
||||
default; successful uninstalls are removed from saved startup state by default.
|
||||
Use `--no-activate-installed-modules` or
|
||||
`--keep-uninstalled-modules-in-desired` only for staged rollout workflows.
|
||||
- Every non-dry installer and live active-graph mutation acquires the
|
||||
deployment-wide `core:module-lifecycle:deployment` lease and records a Core
|
||||
recovery operation. Unresolved effects block later lifecycle changes. The
|
||||
operation modes and operator reconciliation contract are defined in
|
||||
`MODULE_LIFECYCLE_RECOVERY.md`.
|
||||
- `govoplan-module-installer --supervise --migrate --health-url http://127.0.0.1:8000/health --restart-command '<restart govoplan server>'`
|
||||
is the preferred disruptive-change path. It applies the plan, optionally runs
|
||||
migrations in a fresh Python process after a fresh-process manifest
|
||||
@@ -1391,6 +1557,10 @@ The installer preflight is intentionally conservative:
|
||||
- the `shared` state profile blocks in-place package mutation; clustered
|
||||
installations must roll one verified immutable module composition across all
|
||||
replicas;
|
||||
- official runtime images carry the full verified package profile, while the
|
||||
desired module graph controls activation and tenant/View/Policy contracts
|
||||
control availability and presentation; package lifecycle must not be reused
|
||||
as a tenant or user visibility switch;
|
||||
- installed module manifests must be compatible with the supported manifest
|
||||
contract and current core version;
|
||||
- uninstalling `tenancy`, `access`, or `admin` is blocked;
|
||||
@@ -1465,6 +1635,16 @@ Unsigned/unhashed remote bundles are skipped. This keeps remote loading a
|
||||
controlled deployment option rather than a replacement for release package
|
||||
builds.
|
||||
|
||||
A failed local WebUI package import receives one automatic retry after 250 ms.
|
||||
Descriptor validation still fails closed; it is not bypassed by the retry.
|
||||
If an enabled local module still cannot load, the signed-in shell warns that its
|
||||
screens and integrations may be unavailable and identifies the module. This is
|
||||
a loading failure, not an uninstall. Save other drafts before manually reloading
|
||||
the page; there is no automatic page reload or persistent retry loop. A verified
|
||||
remote fallback that successfully loads clears that module's warning. Packages
|
||||
absent from the optional build graph remain absent, and effective View filtering
|
||||
continues to control which loaded UI capabilities are exposed.
|
||||
|
||||
## Maintenance Mode
|
||||
|
||||
Maintenance mode is the required operating state for package install/uninstall
|
||||
@@ -1507,6 +1687,17 @@ URLs never contain credentials; only credential-envelope references cross the
|
||||
contract. Provider-specific details belong in sanitized provenance rather than
|
||||
in a shared domain schema.
|
||||
|
||||
## Semantic Documentation Subject Contract
|
||||
|
||||
Optional modules expose configured artifacts that can be documented through
|
||||
the module-scoped `documentation.semantic_subjects.<module_id>` capability.
|
||||
Core supplies stable tenant-scoped references, typed nested anchors, safe
|
||||
localized descriptors, revision/fingerprint review signals, and explicit
|
||||
availability states. Providers remain responsible for authorization and do not
|
||||
expose configuration payloads or credentials. Docs discovers the capability
|
||||
and owns authored content; it does not import feature internals. See
|
||||
`SEMANTIC_DOCUMENTATION_SUBJECTS.md` for the contract and adoption rules.
|
||||
|
||||
## Build And Verification
|
||||
|
||||
Backend verification from core:
|
||||
|
||||
@@ -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,64 @@
|
||||
# Shared navigation layout contract
|
||||
|
||||
Core owns `NavigationPreferenceEditor`, ordered layout resolution, and rail
|
||||
rendering. Admin, Tenancy, personal Settings and Views reuse this editor. They
|
||||
own loading, authorization, Save, Reload and dirty-state guards; the editor
|
||||
emits a draft only after a real edit. A drag onto the same position, keyboard
|
||||
pickup/drop without movement, and opening inherited settings do not save or
|
||||
create an override.
|
||||
|
||||
## Stored document and precedence
|
||||
|
||||
The version-1 navigation document retains `order`, `hidden` and `locked` and
|
||||
adds optional `separators`, each containing a stable `separator:`-prefixed ID
|
||||
and an optional plain-text label of at most 120 characters. Separator IDs and
|
||||
module navigation IDs occupy the same `order` list. Separators are presentation
|
||||
metadata and never become routes, modules, permissions or authorized surfaces.
|
||||
|
||||
Omitting `separators` or using null preserves inherited grouping. An explicit
|
||||
empty array removes grouping. Resetting the entire navigation document to null
|
||||
removes that scope's override. Existing order-only documents remain readable;
|
||||
the editor materializes group markers into a draft only when edited. Unknown
|
||||
optional-module order IDs remain stored when currently visible items move, so
|
||||
uninstalling or temporarily disabling a module does not destroy its preference.
|
||||
|
||||
User order and visibility take precedence over tenant and system preferences.
|
||||
System and tenant visibility locks accumulate; lower scopes cannot hide those
|
||||
destinations, but may move them. Views may supply a navigation presentation
|
||||
inside the already authorized and View-filtered destination set. An explicit
|
||||
personal order/layout or visibility preference takes precedence over that
|
||||
presentation, not over authorization or the View's surface restrictions.
|
||||
Views cannot introduce locks. When multiple modules contribute one product
|
||||
entry, it inherits the earliest effective rail position/section and all
|
||||
authorized contributors' locks; this does not change operational route
|
||||
selection. An owner alias in View layout refers to that composed entry.
|
||||
|
||||
## Interaction and reuse
|
||||
|
||||
Drag the handle to move either a module or separator before/after another row.
|
||||
The handle also supports Space to pick up, arrow keys to move, Enter to drop,
|
||||
and Escape to restore the pre-drag draft. Up/down buttons offer the same moves.
|
||||
Add module restores an available hidden entry; Remove only hides navigation,
|
||||
never uninstalls a module or deletes records. Add separator inserts a new
|
||||
optional group label. Remove separator changes grouping only.
|
||||
|
||||
Expanded rails display group labels without divider lines. Collapsed rails
|
||||
replace these labels with horizontal group dividers; empty groups are not
|
||||
rendered. Both modes use the same resolved order.
|
||||
The editor receives product-area metadata to show inherited grouping and uses
|
||||
container-responsive rows rather than a fixed dialog/page width. Its English
|
||||
and German labels load with the editor, not the initial shell bundle.
|
||||
Give the ordered list a full-span `GridItem` when a settings page contains
|
||||
multiple cards; do not squeeze the entire editor into an otherwise half-empty
|
||||
two-column settings grid. Central spacing tokens provide real row padding and
|
||||
separation at both wide and narrow sizes, covered by computed-style assertions.
|
||||
|
||||
## Verification
|
||||
|
||||
Core navigation unit tests and HTTP settings/profile tests cover persistence,
|
||||
separator inheritance, explicit flat layouts, reset and locks. Module-capability
|
||||
tests cover View aliases, composed destinations and personal precedence. The
|
||||
browser conformance suite tests all four editor scopes, pointer/keyboard moves,
|
||||
no-op cleanliness, unavailable-module preservation, collapsed dividers and a
|
||||
German narrow read-only layout. Use the same shared component for future
|
||||
navigation-definition surfaces rather than implementing another sortable list.
|
||||
@@ -0,0 +1,208 @@
|
||||
# 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 | Context | Help, Reload when refreshable, then ordinary primary actions |
|
||||
| Collection | Collection context such as export | Help, Reload when refreshable, then Create at the far right |
|
||||
| Detail | Object context | Help, Reload when refreshable, ordinary primary actions, then a separated destructive group |
|
||||
| Editor | Context | Dirty state, Help, Reload if distinctly safe, ordinary primary actions, separated destructive actions, Discard, then Save at the far right |
|
||||
| Workspace | Task context | Help, Reload when refreshable, ordinary primary actions, then a separated destructive group |
|
||||
|
||||
Reload and Create belong to the same right-aligned group, in that order. A
|
||||
collection-wide toolbar stays above its workspace, not inside the left tree or
|
||||
conditionally inside an editor. Changing selection or opening an editor must
|
||||
not remove it. Permission-blocked creation remains visible with an explanation.
|
||||
On narrow screens the trailing group wraps while retaining right alignment and
|
||||
the same DOM/keyboard order.
|
||||
|
||||
Use `Card bodyLayout="table"` for table surfaces, including tables wrapped by
|
||||
`LoadingFrame`. This removes body padding explicitly, without relying on the
|
||||
number of children or negative margins. Place any meaningful explanation or
|
||||
warning in a padded `ContentSection`; do not add a redundant tagline to every
|
||||
table. Use `ContentGrid` for sibling cards so spacing does not depend on fragments.
|
||||
|
||||
Use `MultiSelectFilter` for standalone list facets. It and DataGrid share the
|
||||
same checkbox body and Select all / Deselect all behavior. `null` means no
|
||||
restriction, `[]` means no matches, and multiple values mean OR within a facet.
|
||||
Apply remote filters before server pagination/limits and discard stale reads.
|
||||
Do not replace this with rows of toggles or implement a second checkbox menu.
|
||||
The dropdown's body portal escapes clipped containers. Inside Core dialogs it
|
||||
joins the existing dialog stack: Tab/Shift+Tab stay in the filter, Space toggles
|
||||
the focused checkbox, and Escape closes only the filter and restores its
|
||||
trigger. Long option labels wrap without widening the popup.
|
||||
|
||||
Keep facet definitions, URL serialization and request cancellation in one owning
|
||||
module adapter when the same search appears on a page and in an overlay. Do not
|
||||
translate an explicit empty selection into an unrestricted backend query. Keep
|
||||
legacy API meanings at the adapter boundary; retain scope and unrelated URL
|
||||
parameters when clearing filters. A query, context, account or tenant change
|
||||
invalidates both initial and cursor requests, including results still visible
|
||||
during a debounce interval.
|
||||
|
||||
Explorer workspaces keep collection commands in a persistent header. Files
|
||||
uses Reload, Create folder, then primary Upload; frequent selected-item actions
|
||||
stay near the list. Group less-common selection and connection operations in
|
||||
labelled domain dialogs using `Dialog`, `FormSection` and shared action bars,
|
||||
with an explicit destructive section. Do not move an overloaded toolbar into
|
||||
another ungrouped row. Mail's read-only workspace has one Reload for its current
|
||||
profile, folder, index and preview; narrower refreshes belong in Mailbox tools.
|
||||
Do not invent a New or Save button on a workspace that owns neither workflow.
|
||||
Reload must not become import, synchronization, delivery or another mutation.
|
||||
Explicit Reload reads must bypass short-lived client response reuse (for
|
||||
example, pass `cache: "no-store"` through the owning read API), including each
|
||||
page of a refreshed listing. Routine navigation may retain normal deduplication.
|
||||
Conformance must observe a fresh request, not just an enabled Reload button.
|
||||
|
||||
Tree icons/disclosure controls expand and collapse; labels select. A module's
|
||||
`ExplorerTree.onOpen` must not toggle expansion. Use occurrence-specific node
|
||||
IDs when the same semantic record appears in multiple branches; selection and
|
||||
ancestor expansion must follow the clicked occurrence, not every copy.
|
||||
|
||||
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.
|
||||
@@ -14,6 +14,7 @@ consistent while each module still owns its domain rules.
|
||||
| Governance defaults | `govoplan-admin` plus `govoplan-access` materializer | admin settings, governance template routes, access materialization capability | System governance can block tenant-local groups, roles, and API keys. |
|
||||
| Delegation and ownership policy | access/campaign/mail/files modules | capability checks and owner-scoped APIs | Source provenance should use this contract when policies become externally explainable. |
|
||||
| Definition governance | `govoplan-policy` | capability `policy.definitionGovernance` | Resolves view, edit, run/start, reuse, derive, and automate for system, tenant, group, and user Dataflow/Workflow definitions. |
|
||||
| Function assignment governance | `govoplan-policy` | capability `policy.functionAssignmentGovernance` | Returns current review steps, delegation depth/validity ceilings, and explicit timed-escalation targets consumed by IDM. |
|
||||
|
||||
## Policy Decision
|
||||
|
||||
@@ -126,6 +127,43 @@ When the capability is absent, modules must not silently emulate cross-scope
|
||||
inheritance. Their conservative fallback is limited to local tenant
|
||||
definitions and disables reuse, derivation, and automation.
|
||||
|
||||
## Function Assignment Delegation And Escalation
|
||||
|
||||
`FunctionAssignmentGovernanceDecision` is the versioned cross-module contract
|
||||
for request/grant review. In addition to the required holder, authority, and
|
||||
recipient steps, it returns `delegation_allowed`,
|
||||
`maximum_delegation_depth`, `maximum_delegated_validity_days`, and typed
|
||||
`FunctionAssignmentEscalationRule` entries. Each escalation entry binds one
|
||||
review step to an exact target function and timeout.
|
||||
|
||||
The decision is a current ceiling, not durable authorization. IDM must recheck
|
||||
the complete assignment-source chain and all recorded decisions before final
|
||||
application. An elapsed timeout creates explicit state and evidence; it must
|
||||
never be interpreted as approval or as permission to silently substitute an
|
||||
approver. Missing providers, malformed rules, invalid chains, or tightened
|
||||
limits fail closed with an explainable reason.
|
||||
|
||||
## Bounded Impact-Subject Providers
|
||||
|
||||
Policy impact previews discover optional subject providers through capability
|
||||
names beginning with `policy.impactSubjects.`. The suffix is the stable
|
||||
provider ID; for example, Views contributes `policy.impactSubjects.views`.
|
||||
Providers implement `PolicyImpactSubjectProvider` and receive a
|
||||
`PolicyImpactPopulationRequest` containing the active tenant, policy family,
|
||||
an explicit selector, actor scopes, detail-disclosure decision, and a limit of
|
||||
at most 500. They return `PolicyImpactSubjectBatch` with unique opaque subject
|
||||
references and an explicit `complete`, `sampled`, `truncated`, or `unavailable`
|
||||
state. An unavailable batch must explain the gap, and a total may never be
|
||||
smaller than the returned subject count.
|
||||
|
||||
Core does not scan module data or evaluate domain policy. The owning module
|
||||
selects and permission-filters its candidates; Policy compares the current and
|
||||
proposed decisions and controls response disclosure. A caller must select one
|
||||
or more provider populations explicitly. This preserves optional-module
|
||||
boundaries and prevents a seemingly harmless preview from becoming an
|
||||
unbounded platform query. Providers must not include credentials, secrets, or
|
||||
unfiltered cross-tenant labels in subject attributes.
|
||||
|
||||
## Frontend Contract
|
||||
|
||||
Policy UIs must:
|
||||
@@ -138,6 +176,9 @@ Policy UIs must:
|
||||
lower-level limit to `false`
|
||||
- avoid sending locked fields or re-enable attempts in save payloads
|
||||
- show inherited values separately from local overrides
|
||||
- require a current impact preview before enabling a governed high-impact save,
|
||||
preserve its proposal hash on commit, and explain incomplete population
|
||||
coverage rather than presenting unavailable providers as zero impact
|
||||
|
||||
The core WebUI helper `privacyRetentionParentAllowsField()` centralizes the
|
||||
field-lock decision used by the retention editor and its lightweight module
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
# Postbox End-To-End Encryption Architecture
|
||||
|
||||
This document records the strategic encryption target for GovOPlaN postboxes.
|
||||
It does not require the first postbox implementation to ship full E2EE, but it
|
||||
defines the architecture so early data models and APIs do not make the stronger
|
||||
model impossible.
|
||||
This document records the encryption boundary for GovOPlaN postboxes. Postbox
|
||||
now implements the server-side contracts for three selectable profiles:
|
||||
unencrypted content, institution-managed server envelopes, and externally
|
||||
produced E2EE envelopes. The E2EE contract is operational—the server rejects
|
||||
plaintext and retains ciphertext, signed manifests, wrapped keys, and digest
|
||||
evidence—but a reviewed browser/device client and private-key custody provider
|
||||
remain separately deployed responsibilities.
|
||||
|
||||
The core principle is that a postbox can become a trusted administrative
|
||||
communication channel without requiring the server to see plaintext content.
|
||||
@@ -35,6 +38,54 @@ Algorithm choices should remain replaceable behind a crypto profile. The first
|
||||
profile should prefer standard, reviewed primitives such as HPKE for key
|
||||
wrapping and AEAD encryption for content.
|
||||
|
||||
## Product Profiles And Default
|
||||
|
||||
The content-protection policy is configurable per exact Postbox or immutable
|
||||
template revision:
|
||||
|
||||
- `server_envelope_v1` is the recommended default. An institution-selected
|
||||
Encryption vault controls server-readable envelopes and their migration
|
||||
evidence. It is not end-to-end encryption.
|
||||
- `external_e2ee_v1` is server-blind. An approved client or producer supplies
|
||||
the ciphertext reference, signed manifest, wrapped recipient keys, key epoch,
|
||||
and SHA-256 plaintext digest. GovOPlaN has no private key that can decrypt it.
|
||||
- `plaintext_v1` stores clear content for institutions that explicitly choose
|
||||
that boundary.
|
||||
|
||||
Operational metadata—including subject, routing, participants,
|
||||
classifications, timestamps, attachment references, receipts, and retention
|
||||
state—remains visible under every profile. Administrators therefore choose a
|
||||
content-protection boundary, not a metadata-anonymity profile.
|
||||
|
||||
The standard policy grants new incumbents history since assignment, uses key
|
||||
rewrapping for ordinary rotation and content re-encryption after compromise,
|
||||
requires two-person institutional recovery and dual-control hand-over,
|
||||
emergency, export, and destruction, requires strong external identity, and
|
||||
limits vacancy escalation to metadata. Deployments may select other policy
|
||||
values rather than inheriting a decision from GovOPlaN.
|
||||
|
||||
## Governed Profile Changes
|
||||
|
||||
A profile transition applies to new messages immediately and increments the
|
||||
Postbox key epoch. Retained history can remain under the previous profile or be
|
||||
migrated. The transition ledger records source and target profiles/vaults,
|
||||
authority route, consent and key-holder evidence, quorum, reason, immutable
|
||||
configuration snapshot, per-message source and target digest, and outcome.
|
||||
|
||||
Plaintext and managed-envelope migrations can use the server-side Encryption
|
||||
capability. Managed decrypt, export, and re-encryption operations also create
|
||||
Encryption migration records so old envelopes are disposed of through the
|
||||
governed provider contract. Any transition to or from E2EE pauses each retained
|
||||
message for an approved client transform. The client must return plaintext or
|
||||
ciphertext as appropriate, plus evidence and the original content digest;
|
||||
Postbox verifies digest continuity before changing the stored representation.
|
||||
Leaving E2EE requires user-consent evidence, while changing managed history
|
||||
requires institutional key-holder evidence. Dual control can require both.
|
||||
|
||||
This transition mechanism cannot revoke plaintext already decrypted, copied,
|
||||
printed, or exported. Administrators must explicitly acknowledge that residual
|
||||
disclosure before a transition is accepted.
|
||||
|
||||
## Identity And Device Keys
|
||||
|
||||
The platform should distinguish:
|
||||
|
||||
@@ -141,6 +141,73 @@ connector or module issue.
|
||||
- Owner/priority: `govoplan-mail`, `govoplan-calendar`,
|
||||
`govoplan-connectors`, Wave 1/2.
|
||||
|
||||
#### Collaboration-suite boundary and hand-offs
|
||||
|
||||
Collaboration remains connector-first. The product named below never changes
|
||||
which GovOPlaN module owns the administrative meaning of the work:
|
||||
|
||||
| External family | Initial posture | GovOPlaN semantic owner | Connector-owned boundary |
|
||||
| --- | --- | --- | --- |
|
||||
| Collabora Online, OnlyOffice, Nextcloud Office | Link an externally edited document and its editing session; import a governed rendition only when required | DMS owns document/version, lock, review, approval, retention, and collaboration-session evidence; Files owns stored bytes | Discovery, endpoint health, WOPI/vendor session exchange, callbacks, and provider object references |
|
||||
| Matrix, Mattermost, Rocket.Chat, Nextcloud Talk | Create or link a room/thread for a governed work context; do not mirror all conversation history by default | The initiating Case, Workflow, or Task owns the work-context link and disposition; DMS/Records own retained evidence deliberately captured from it | Room/thread creation, membership synchronization, webhook/event normalization, and stable external links |
|
||||
| Jitsi and BigBlueButton | Provision or link a conference for an existing appointment/event | Appointments owns booking intent; Calendar owns event, attendee, invitation, and time semantics | Conference provisioning, join/moderator references, provider lifecycle, and bounded attendance/result callbacks |
|
||||
| OpenProject and comparable project suites | Link first, then publish or synchronize selected work packages | Tasks owns GovOPlaN task state; Workflow owns orchestration; Cases own case state and evidence references | Project/work-package lookup, publish/synchronize transport, webhooks, version tokens, and external URLs |
|
||||
| Cross-suite activity streams | Consume normalized, bounded events only for an authorized work context | The receiving module decides whether an event changes state or becomes evidence; Audit records the GovOPlaN operation | Provider subscriptions, cursor/checkpoint handling, signature validation, event normalization, and replay protection |
|
||||
|
||||
Native collaboration behavior is justified only when GovOPlaN must own the
|
||||
semantic state, authorization decision, audit evidence, retention/legal-hold
|
||||
rule, or configuration-package fragment. Endpoint profiles, tokens, health,
|
||||
protocol clients, provider IDs, retries, and webhook transport remain in
|
||||
Connectors (or the owning protocol connector). A feature module consumes a
|
||||
Core capability/DTO and must still start and fail explicitly when that optional
|
||||
connector is absent; it never imports a provider client.
|
||||
|
||||
The minimum hand-off sequences are:
|
||||
|
||||
1. **Appointment to conference:** Appointments confirms the booking intent;
|
||||
Calendar creates or updates the event and invitations; an optional
|
||||
conference connector provisions the room idempotently and returns an
|
||||
opaque join reference. Calendar stores that reference with the event, not
|
||||
the provider credential.
|
||||
2. **Case or Workflow to collaborative document:** the initiating module asks
|
||||
DMS for a governed document/session; DMS requests an optional office-suite
|
||||
connector session and retains version, lock, approval, and callback
|
||||
evidence. The Case/Workflow keeps only the DMS reference.
|
||||
3. **Case, Workflow, or Task to chat:** the semantic owner requests a room or
|
||||
thread with an idempotency key and bounded membership intent. The connector
|
||||
returns an external reference; capturing messages as evidence requires an
|
||||
explicit DMS/Records action and policy decision.
|
||||
4. **Task or Workflow to project suite:** Tasks supplies the task payload and
|
||||
Workflow supplies correlation; the OpenProject connector publishes or
|
||||
reconciles the work package and returns versioned external-reference and
|
||||
retry/conflict evidence. Neither consumer writes connector tables.
|
||||
|
||||
Every executable collaboration connector must pass the common connector
|
||||
contract checks plus a provider-focused minimum proof:
|
||||
|
||||
- optional-module startup and partial compositions work without the provider;
|
||||
- profile health uses secret references and redacts credentials and remote
|
||||
response bodies;
|
||||
- tenant/resource authorization is checked before discovery, provisioning,
|
||||
lookup, synchronization, or evidence capture;
|
||||
- dry-run/simulation performs no remote mutation and explains unsupported
|
||||
operations;
|
||||
- create/publish calls are idempotent, retries preserve the same external
|
||||
reference, and outcome-unknown or version conflicts remain reconcilable;
|
||||
- callbacks/webhooks verify authenticity, tenant/profile binding, replay
|
||||
protection, and bounded payloads;
|
||||
- disable/retire behavior revokes new use while preserving non-secret audit and
|
||||
external-reference evidence;
|
||||
- Collabora/OnlyOffice prove discovery plus one non-production editing-session
|
||||
round trip; Matrix/Mattermost/Rocket.Chat prove room lookup/create plus one
|
||||
authenticated bounded event; Jitsi/BigBlueButton prove conference
|
||||
provision/cancel; OpenProject proves project/work-package lookup, idempotent
|
||||
publish, and conflict handling.
|
||||
|
||||
These are connector acceptance tests, not a claim that those connectors are
|
||||
already implemented. Their implementation state remains in the owning
|
||||
connector issues and catalogue.
|
||||
|
||||
### Payment And Public Cashier Systems
|
||||
|
||||
- Strategy: integrate/export/import; keep the payment provider or cashier as
|
||||
|
||||
@@ -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.
|
||||
@@ -95,6 +95,15 @@ package entry. Keep one committed full-product release lockfile at
|
||||
`webui/package.release.json` in a clean release workspace. Development
|
||||
`package-lock.json` may continue to point at local `file:` dependencies.
|
||||
|
||||
The default WebUI build discovers every declared, installed module package,
|
||||
including Tasks and its Work page, dashboard widget, and Quick Access tool.
|
||||
Discovery still respects enabled backend modules and user permissions; adding
|
||||
a package never grants access. `GOVOPLAN_WEBUI_MODULE_PACKAGES` selects an
|
||||
explicit smaller build when set, and an explicitly empty value selects
|
||||
core-only. The prebuild interface check compares the default descriptor list
|
||||
with the package manifest so explicit permutation tests cannot conceal a
|
||||
module accidentally omitted from the ordinary build.
|
||||
|
||||
Frontend module permutations are regression-tested through
|
||||
`GOVOPLAN_WEBUI_MODULE_PACKAGES` and temporary build output, not through
|
||||
committed lockfiles for every possible combination. If a smaller composition
|
||||
@@ -197,6 +206,13 @@ If both file and URL are set, the URL wins. The cache is used when a remote
|
||||
fetch fails, so an operator can still inspect the last known catalog. A cached
|
||||
catalog must still pass signature, freshness, channel, and replay validation.
|
||||
|
||||
If neither source is configured, the Admin package directory discovers the
|
||||
official public stable catalog at
|
||||
`https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json`. Core verifies
|
||||
that fallback against the public key pinned in the installed Core package. An
|
||||
explicit deployment catalog always takes precedence; a configured source that
|
||||
is unavailable or invalid fails closed instead of silently falling back.
|
||||
|
||||
An official catalog is a JSON object with:
|
||||
|
||||
- `catalog_version`
|
||||
@@ -212,6 +228,14 @@ Each module entry can declare:
|
||||
|
||||
- backend package name and pinned install reference
|
||||
- WebUI package name and pinned install reference
|
||||
- `artifact_integrity` for each package, including the HTTPS registry URL,
|
||||
filename, byte size, SHA-256, package identity, source tag, and source commit
|
||||
- `source`, binding the repository and immutable tag/commit identity, with
|
||||
optional HTTPS repository and revision links
|
||||
- `availability`, either `available` or `withdrawn`; a withdrawn entry must
|
||||
carry an operator-readable `availability_reason` and cannot be planned
|
||||
- `configuration_requirements` and an optional HTTPS `release_notes_url` for
|
||||
prerequisites and release-specific operator guidance
|
||||
- display metadata and tags
|
||||
- `license_features`, the feature entitlements required to plan that install
|
||||
- `dependencies` and `optional_dependencies`, the module ids expected in the
|
||||
@@ -239,6 +263,12 @@ Each module entry can declare:
|
||||
- `requires_interfaces`, named interface contracts and version ranges required
|
||||
by this module
|
||||
|
||||
Core validates these fields before exposing the directory. Admin derives a
|
||||
read-only catalog state from the installed package set, catalog dependency
|
||||
closure, named-interface providers, current-version window, availability, and
|
||||
generic license policy. This is an early operator diagnostic; trusted installer
|
||||
preflight remains the authoritative mutation gate.
|
||||
|
||||
The signature is Ed25519 over canonical JSON with both `signature` and
|
||||
`signatures` removed. Core accepts the legacy single `signature` field and the
|
||||
new `signatures` array.
|
||||
@@ -302,6 +332,12 @@ Catalog provenance changes preflight severity:
|
||||
plans, so operators can still use offline or emergency package refs
|
||||
- valid-catalog warnings, such as intentionally unsigned local catalogs when
|
||||
signature enforcement is disabled, remain warnings
|
||||
- a saved catalog plan must match the currently validated entry exactly;
|
||||
altered package refs, artifact identities, channel, sequence, trust state, or
|
||||
signing-key identity block the run and require replanning
|
||||
- a trusted remote artifact is downloaded before mutation into a private
|
||||
SHA-256-addressed installer cache, checked for exact size and digest, and
|
||||
passed to `pip` or npm only as that verified local file
|
||||
- selected catalog entries with unsatisfied non-optional named interface ranges
|
||||
block activation before the installer runs
|
||||
- selected catalog entries whose target dependencies are neither installed nor
|
||||
@@ -519,6 +555,11 @@ Catalog entries can require license features:
|
||||
Core checks those requirements against an offline license file before allowing
|
||||
the entry into the install plan.
|
||||
|
||||
Official open-source GovOPlaN entries do not declare license features. The
|
||||
license contract remains generic for external catalogs, deployment presets,
|
||||
configuration/package directories, and support offerings; it gates only an
|
||||
entry that explicitly asks for a feature.
|
||||
|
||||
```bash
|
||||
GOVOPLAN_LICENSE_FILE=/srv/govoplan/license.json
|
||||
GOVOPLAN_LICENSE_ENFORCEMENT=true
|
||||
|
||||
@@ -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,6 @@ tools/checks/security-audit/run.sh --mode full --scope govoplan
|
||||
|
||||
Canonical documentation:
|
||||
|
||||
- `/mnt/DATA/git/govoplan/docs/SECURITY_AUDIT.md`
|
||||
- `/mnt/DATA/git/govoplan/docs/operations/SECURITY_AUDIT.md`
|
||||
|
||||
Implementation contract: [disposable resource-bounded operations](BOUNDED_PROCESS_CONTRACT.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.
|
||||
@@ -28,6 +28,11 @@ 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
|
||||
@@ -88,10 +93,17 @@ Alembic, and post-migration tasks. The lock is session-scoped and therefore
|
||||
released if the migration process dies.
|
||||
|
||||
Runtime roles run `govoplan_core.commands.wait_for_database`. It waits until
|
||||
the database has exactly the configured Core/module Alembic heads and never
|
||||
upgrades schema. This permits a migration Job and runtime Deployments to be
|
||||
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
|
||||
@@ -128,7 +140,7 @@ Core does not create production database backups. The deployment owner must
|
||||
provide backup, retention, encryption, restore verification, and recovery-point
|
||||
coordination for PostgreSQL, object storage, and encryption keys. The canonical
|
||||
operator procedure is documented in
|
||||
`govoplan/docs/RECOVERY_AND_ROLLBACK_GUARANTEES.md`.
|
||||
`govoplan/docs/operations/RECOVERY_AND_ROLLBACK_GUARANTEES.md`.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# 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.
|
||||
|
||||
## Durable CSV imports and original evidence
|
||||
|
||||
`TabularCsvSource` optionally accompanies a durable `TabularSnapshotInput` or
|
||||
`DatasourceStageInput`. It carries the exact submitted Unicode text, delimiter,
|
||||
explicit value mode and parser profile. It is not part of ordinary catalogue,
|
||||
preview or stage DTOs. Transient inspection does not retain original content.
|
||||
|
||||
The `text` mode preserves cell strings, including whitespace, leading zeroes,
|
||||
decimal spelling, boolean-looking text and explicit empty cells. It rejects
|
||||
malformed quoting and rows with missing or extra cells. Header normalization is
|
||||
unchanged. The API default remains `legacy_typed` for existing integrations;
|
||||
interactive CSV imports offer text mode by default and an explicit legacy choice.
|
||||
JSON and existing stored snapshots are not reinterpreted. Core and Datasources
|
||||
retain distinct versioned legacy parser profiles where their historical coercion
|
||||
rules differ. Shared schema inference preserves first-seen column order, missing
|
||||
value nullability and the owning provider's type naming.
|
||||
|
||||
Owners verify that the source reparses to exactly the stored projection, including
|
||||
scalar types: `true`, `1` and `1.0` are not equivalent evidence. Raw input and row
|
||||
projections each have a 5,000,000-byte limit; row parsing is capped at 10,000 rows.
|
||||
The original text has its own UTF-8 SHA-256 and byte count, separate from the
|
||||
existing row fingerprint. Only a small allowlisted source summary enters metadata.
|
||||
Checksums detect drift; they are not digital signatures or protection against an
|
||||
attacker who can rewrite the database and all its evidence.
|
||||
|
||||
Original exports are explicit owner APIs, tenant scoped, integrity checked and
|
||||
`no-store`. Datasources additionally requires administrator scope, audits the
|
||||
export, and denies the whole original when current or historical governance
|
||||
restricts any row or field. Freezing verifies the prior summary before copying
|
||||
source evidence and retains prior policy restrictions, including referenced
|
||||
policy evaluations. At most 32 distinct governance snapshots may accompany one
|
||||
original; further incompatible history fails explicitly. Payload disposal also
|
||||
disposes retained original content. Connectors applies its own current read,
|
||||
tenant and lifecycle checks. See the owning module's documentation for endpoints.
|
||||
|
||||
Original UTF-8 text is not proof of pre-decoding file bytes, and does not undo
|
||||
CSV spreadsheet formula semantics. Exported content is deliberately unmodified;
|
||||
operators must treat it as untrusted input when opening it in a spreadsheet.
|
||||
New nullable columns require the owning modules' additive migrations. Historical
|
||||
rows remain unchanged and report original content unavailable, not reconstructed.
|
||||
Back up retained originals before any schema downgrade that removes those columns.
|
||||
|
||||
## Deutsch: CSV-Datentreue
|
||||
|
||||
Dauerhafte CSV-Importe können den unveränderten übermittelten Unicode-Text mit
|
||||
Trennzeichen, Parserprofil und explizitem Wertemodus aufbewahren. Der Textmodus
|
||||
erhält Zellwerte einschließlich Leerzeichen, führender Nullen und Dezimalschreibweise.
|
||||
Fehlerhafte Zeilen werden abgewiesen. Die API bleibt aus Kompatibilitätsgründen bei
|
||||
der bisherigen Typumwandlung als Standard; im Importdialog ist Text voreingestellt.
|
||||
Bestehende Daten und JSON-Importe werden nicht neu interpretiert.
|
||||
|
||||
Original und Zeilenprojektion werden getrennt begrenzt und geprüft; boolesche
|
||||
Werte, Ganzzahlen und Gleitkommazahlen sind keine austauschbaren Belege. Der
|
||||
Originaltext erscheint weder im Katalog noch in Vorschauantworten. Die expliziten
|
||||
Export-APIs prüfen Mandant, Berechtigungen, Lebenszyklus und gespeicherten Hash.
|
||||
Datasources verlangt zusätzlich Administrationsrechte, protokolliert Exporte und
|
||||
berücksichtigt aktuelle sowie historische Zeilen-, Feld- und Zugriffsrichtlinien.
|
||||
Eingeschränkte Originale werden vollständig gesperrt, nicht teilweise freigegeben.
|
||||
Eingefrorene Kopien übernehmen diese Einschränkungen; nach 32 unterschiedlichen
|
||||
Richtlinienständen wird eine weitere Kopie mit neuer Richtlinie explizit abgewiesen.
|
||||
Die Aufbewahrungsbereinigung entfernt auch das gespeicherte Original.
|
||||
|
||||
Die Grenzen betragen jeweils 5.000.000 UTF-8-Bytes für Original und Projektion
|
||||
sowie 10.000 Zeilen. Prüfsummen sind keine Signaturen. Ein CSV-Original bleibt beim
|
||||
Export unverändert und kann Tabellenkalkulationsformeln enthalten. Frühere
|
||||
Dateikodierungen lassen sich daraus nicht rekonstruieren. Additive Migrationen
|
||||
ändern keine historischen Zeilen; fehlende Originale werden nicht erfunden.
|
||||
Vor einem Schema-Downgrade sind aufbewahrte Originale zu sichern.
|
||||
@@ -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.
|
||||
+39
-5
@@ -4,6 +4,9 @@ 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
|
||||
|
||||
@@ -12,17 +15,48 @@ root and exposes the selected preference through `data-theme-preference`.
|
||||
- Modules consume semantic tokens such as `--surface`, `--text`, `--line`, and
|
||||
the status token families. They may define domain aliases whose values resolve
|
||||
to shared tokens.
|
||||
- User preference selects the mode. Tenant and system policy may provide a
|
||||
future default, but must not silently replace an explicit user choice.
|
||||
- Tenant branding is a separate policy surface and must preserve contrast and
|
||||
status semantics in both modes.
|
||||
- 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 the root behavior and representative
|
||||
`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.
|
||||
|
||||
Runtime validation and token application live in the dependency-free
|
||||
`webui/src/components/appearanceOverrides.ts`; both the shell and the shared
|
||||
editor use it. The shell must not import the editor to apply an existing theme:
|
||||
settings controls load with their route, while valid saved colors apply
|
||||
synchronously and invalid documents still fail closed before any token is set.
|
||||
|
||||
@@ -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.
|
||||
@@ -50,6 +50,15 @@ contestability, responsibility, and traceability at the point of action.
|
||||
| UX-024 | Explicit `Discard` actions and dirty in-application navigation use the shared `UnsavedChangesProvider` dialog. A page registers save/discard behavior with `useUnsavedDraftGuard`; its Discard button calls `requestDiscard`, and route changes use `useGuardedNavigate` or `requestNavigation`. | Accepted | All create/edit surfaces |
|
||||
| UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code |
|
||||
| UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables |
|
||||
| UX-027 | The platform icon rail keeps its brand header and utility footer visible. Only the module-navigation region scrolls when installed and permitted modules exceed the available viewport height. | Accepted | Core WebUI shell |
|
||||
| UX-028 | Maintenance and offline states use their established textual warning banners centered in the titlebar. They must not recolor the shell or add decorative status icons. Global search is a right-side command, so warnings do not replace its trigger or overlay. | Accepted | Core WebUI shell |
|
||||
| UX-029 | Recoverable page and module errors use the central compact `DismissibleAlert` presentation with an explicit recovery action where one exists. Full-height workspaces must overlay page feedback instead of allowing an alert to become a stretched workspace row. | Accepted | Core and module WebUIs |
|
||||
| UX-030 | At narrow widths, the titlebar uses separate context and command rows. Context selectors remain horizontally reachable, search retains its compact trigger, and language/help/notification/account commands remain fixed icon controls without overlap. Shared content padding contracts so domain workspaces retain usable width. | Accepted | Core WebUI shell and all module workspaces |
|
||||
| UX-031 | Public controls and extension contributions use stable, module-namespaced interface identities. Shared controls expose `interfaceId` and `helpTopicId`; generated source anchors are inventory evidence, not a substitute for an explicit ID when documentation, policy, or automation refers to the control. | Accepted | Core and module WebUIs |
|
||||
| UX-032 | `F1` resolves help from the focused field or action, then its dialog/section/page and registered route. Focused contexts retain the page fallback; Docs applies audience and permission filtering and falls back to visible module documentation. | Accepted | Core shell, Docs, and all module WebUIs |
|
||||
| UX-033 | Global search is the left-most titlebar command, immediately before language selection. Its icon, `F3`, and `Ctrl`/`Cmd`+`K` all open the same permission-aware search overlay; the titlebar does not reserve a persistent query field. | Accepted | Core shell and Search WebUI |
|
||||
| UX-034 | Every headed `PageLayout` declares one of `overview`, `collection`, `detail`, `editor`, or `workspace` independently from its standalone/workspace/embedded geometry. Its actions use the matching semantic `PageActionBar`: refreshable pages provide Reload in the right-aligned trailing group immediately before Create/primary actions; collections keep Create far right; read-only pages do not invent Save. The trailing placement supersedes the earlier leading-Reload rule (2026-09-07, Core #295). | Accepted | Core and all module WebUIs |
|
||||
| UX-035 | Editor action bars expose clean, dirty, and saving state; always retain Discard immediately before the far-right Save; centrally disable both while clean or saving; and participate in the unsaved-change navigation guard. Danger actions occupy the explicit separated destructive group after ordinary actions and before editor persistence. | Accepted | Core and all module WebUIs |
|
||||
|
||||
## Confirmed Implementation Decisions
|
||||
|
||||
@@ -156,14 +165,19 @@ Decision: the WebUI shell exposes a small, stable appearance contract based on
|
||||
shared CSS tokens and persisted user preference selection.
|
||||
|
||||
- Core applies `system`, `light`, and `dark` preferences at the document root.
|
||||
- Core applies validated user accent presets through `data-palette`; palette
|
||||
values change semantic tokens globally and never require module CSS changes.
|
||||
- Core owns shared tokens such as `--bg`, `--bar`, `--panel`, `--surface`,
|
||||
`--line`, `--line-dark`, `--text`, `--text-strong`, `--muted`, semantic
|
||||
status colors, radii, shadows, and disabled-control colors.
|
||||
- Modules must style new UI with these tokens and shared controls. Module-local
|
||||
CSS may tune layout and spacing, but it must not introduce a separate
|
||||
appearance system.
|
||||
- Appearance controls live in user settings first. Tenant defaults and policy
|
||||
enforcement can be added later without changing the token contract.
|
||||
- Appearance controls live in user settings. A personal palette wins over
|
||||
unlocked tenant and system defaults; system and tenant locks take precedence.
|
||||
Advanced personal token overrides additionally require system opt-in and may
|
||||
be narrowed by tenant policy. Their versioned import/export document is
|
||||
validated and applied all-or-nothing in both light and dark modes.
|
||||
- Visual preview in settings is illustrative; it must reflect token families,
|
||||
not become a second theme implementation.
|
||||
|
||||
@@ -222,7 +236,13 @@ instead of reproducing their behavior.
|
||||
not self-explanatory.
|
||||
- `help` content is contextual guidance, not the accessible name. The persisted
|
||||
`show_inline_help_hints` user preference hides only the `InlineHelp` marker by
|
||||
applying `ui-hide-help-hints` at the document root.
|
||||
applying `ui-hide-help-hints` at the document root. When shown, the shared
|
||||
marker is a labelled, keyboard-focusable help control and exposes its tooltip
|
||||
on focus as well as pointer hover.
|
||||
- Shared action-bearing components accept an optional disabled reason. In
|
||||
particular, `MailServerSettingsPanel` forwards protocol-specific test
|
||||
blockers into the shared focusable disabled-action tooltip; modules provide
|
||||
the domain-specific required field, permission, or in-progress reason.
|
||||
- A dirty editor registers once with `useUnsavedDraftGuard`. An explicit
|
||||
Discard button calls `useUnsavedChanges().requestDiscard(afterResolve)`; SPA
|
||||
navigation uses `useGuardedNavigate` or `requestNavigation`. Both paths show
|
||||
@@ -272,7 +292,7 @@ UI documentation until a central cross-repository audit is available.
|
||||
|
||||
| Core scope | Why `FieldLabel` is omitted | Accessible/context label source |
|
||||
| --- | --- | --- |
|
||||
| `PasswordField`, `ColorPickerField`, `DateField`, `TimeField`, and `DateTimeField` input internals | These are label-neutral composite primitives and are placed inside `FormField`/`FieldLabel` by the consuming form. Rendering another label inside the primitive would duplicate it. | Enclosing label; a direct consumer must pass an accessible name and record that direct composition here. |
|
||||
| `PasswordField`, `ColorPickerField`, `DateField`, `TimeField`, and `DateTimeField` input internals | These are label-neutral composite primitives and are placed inside `FormField`/`FieldLabel` by the consuming form. Rendering another label inside the primitive would duplicate it. `PasswordField` may opt into the shared cryptographic generator; the candidate dialog is subordinate to the enclosing field and commits only through its explicit Use action. | Enclosing label; a direct consumer must pass an accessible name and record that direct composition here. |
|
||||
| `ToggleSwitch` native checkbox | The shared component already renders its visible text through `FieldLabel`; the native input must not render a second label. | The enclosing native label and derived `aria-label`. |
|
||||
| `FileDropZone` hidden file input | The input is an implementation detail of the labelled keyboard-operable drop target. | Drop target text and `inputLabel`/`aria-label`. |
|
||||
| `AdminSelectionList` and `DataGrid` list-filter checkboxes | Each option is self-explanatory and already enclosed by its visible option label. | Enclosing native option label. |
|
||||
@@ -303,14 +323,14 @@ converted or reviewed.
|
||||
|
||||
| Surface | Repository | UX State | Next Action |
|
||||
| --- | --- | --- | --- |
|
||||
| File connector settings | `govoplan-files` | First adaptive modal slice started: connections and credentials now use full-state create/edit forms with conditional fields, advanced panels, and blocker primitives. Wizard shell is retained for later assisted setup. Central policy card still needs a layered editor. | Finish provider discovery/test-in-flow, then convert policy editing. |
|
||||
| Mail server settings | `govoplan-mail` / `govoplan-core` | Uses the shared server/credential model visually, but create/edit still needs the same adaptive pattern as files. | Migrate to adaptive server/credential/policy dialogs, with optional assisted wizard later. |
|
||||
| File connector settings | `govoplan-files` | Migrated to the shared adaptive server/credential/policy pattern with provider discovery, typed controls, actionable blockers, consequence-aware removal, and module-owned verification evidence. | Continue only through bounded Files-owned follow-ups. |
|
||||
| Mail server settings | `govoplan-mail` / `govoplan-core` | Migrated to the same layered profile/server/credential/policy pattern, including focused connection tests, unsaved-state handling, contextual help, and permission/target blockers. | Continue only through bounded Mail-owned follow-ups. |
|
||||
| Connector policy/effective rows | `govoplan-core`, module UIs | Effective-policy direction exists, but many editors still expose broad option sets. | Put effective value first, move overrides into modal, and explain blocked edits. |
|
||||
| Admin module management | `govoplan-admin` | Has preflight concepts, but operational choices are still technical and dense. | Convert install/uninstall/package changes to operator wizards. |
|
||||
| Configuration packages | `govoplan-admin` | Catalog/import work exists, but package editing can still drift toward technical fields. | Add guided import/review/problem-list flow. |
|
||||
| Retention and privacy | `govoplan-core` | Functional editor exists; consequence language and provenance can be stronger. | Layer advanced retention options and add review for broad changes. |
|
||||
| Retention and privacy | `govoplan-core` | Typed effective-policy editor exposes source paths, narrowing semantics, platform locks, permission/target blockers, and explicit clean/loading/save states. | Broader governed-change review remains module-owned where a policy change requires approval. |
|
||||
| API keys | `govoplan-access` / admin UI | Security-sensitive creation needs least-privilege guidance. | Add scoped creation wizard with expiry/owner review. |
|
||||
| User settings | `govoplan-core` | Preferences persistence exists; interface navigation issue was fixed earlier, but the surface still needs UX review. | Keep simple sections, remove double-click traps, and add quiet explanations. |
|
||||
| User settings | `govoplan-core` | Simple typed sections use unsaved-change guards, quiet result feedback, contextual help, explicit busy/clean disabled-action reasons, and an effective appearance source. Palette selection and light/dark preview are shared with system and tenant administration. | Keep bounded; new contributed sections must satisfy the checklist. |
|
||||
|
||||
## Impact Index
|
||||
|
||||
@@ -326,7 +346,7 @@ converted or reviewed.
|
||||
| Automation/workflow commands | Hidden side effects would undermine accountability. | Action/effect preview, system-actor display, command record, retry/quarantine/manual states, and audit links. |
|
||||
| Postbox and encrypted communication | Retraction and access can be misunderstood. | Honest key-fetch/decryption state, expiry limits, recipient/device access provenance, and delivery evidence. |
|
||||
| API keys | Security-sensitive creation and scope selection. | Scoped creation wizard, least-privilege suggestions, clear expiry/owner explanation. |
|
||||
| User settings | Needs clarity and persistence across profile/interface/preferences. | Simple settings sections with immediate feedback and no double-click navigation traps. |
|
||||
| User settings | Needs clarity and persistence across profile/interface/preferences. | Simple settings sections with immediate feedback, explicit inherit/reset semantics, effective-source provenance, and no double-click navigation traps. |
|
||||
|
||||
## Review Checklist
|
||||
|
||||
@@ -339,6 +359,11 @@ Every new or changed admin/configuration surface should answer:
|
||||
- Does the screen explain disabled actions and failed validation in plain
|
||||
language?
|
||||
- Does it say who can fix a blocker and where?
|
||||
- Does a module-localized blocker pass its translated row labels through the
|
||||
shared `ActionBlockerHint` contract instead of reproducing the component?
|
||||
- Does longer field or blocker guidance use a stable `DocumentationHelpLink`
|
||||
topic/context reference, with hosted fallback when the optional Docs module
|
||||
is absent?
|
||||
- Does it reuse existing core patterns for wizard steps, problem lists, modals,
|
||||
help, and review?
|
||||
- Is there a review or preflight step before broad, destructive, or risky
|
||||
|
||||
@@ -59,6 +59,14 @@ The first budgeted full-product build reported:
|
||||
|
||||
## Verification
|
||||
|
||||
Development startup explicitly prebundles the Excel reader's browser/universal
|
||||
entrypoints and the lazy rich-text editor's Tiptap dependencies. These are
|
||||
Core-installed vendor dependencies, not eager optional-module imports. This
|
||||
avoids first-time Campaign/Template navigation triggering a second dependency
|
||||
optimization and page reload. Module descriptors and pages remain lazy, and
|
||||
production bundle budgets remain unchanged. The Core interface-pattern check
|
||||
verifies this include list and keeps optional GovOPlaN modules excluded.
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core/webui
|
||||
npm run build
|
||||
@@ -69,3 +77,33 @@ 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.
|
||||
|
||||
The startup shell imports appearance validation/application from the pure
|
||||
`appearanceOverrides.ts` runtime. Settings-only color controls, JSON import/export,
|
||||
and previews remain in `AppearanceOverridesEditor.tsx` behind the existing lazy
|
||||
Settings route. Importing a runtime helper from a module that also owns editor
|
||||
components can accidentally pull the entire editor into the startup chunk.
|
||||
Public helper exports remain compatible; theme application is still synchronous.
|
||||
The versioned default color document and its deep-clone helper live in
|
||||
`appearanceOverrideDefaults.ts`, loaded with that editor. Applying saved overrides
|
||||
does not load editor defaults or construct a draft. The default values and public
|
||||
helper names are unchanged; the theme regression checks independent draft clones
|
||||
as well as synchronous validation, application, and reset.
|
||||
|
||||
`PasswordField` keeps ordinary input and reveal controls synchronous. Its
|
||||
optional `PasswordGeneratorDialog` is imported only after an enabled, editable
|
||||
generator is explicitly opened, not for every sign-in/password field. Loading
|
||||
and failures use the shared resource boundary; the underlying field remains
|
||||
usable. Closing or revoking generation while loading cannot apply a candidate.
|
||||
The secure browser RNG, generation policy, public exports, and explicit
|
||||
"Use password" confirmation remain unchanged. The isolated browser fixture
|
||||
does not import the Core barrel, so it can verify that the generator is not
|
||||
requested before opening it, along with cancel/use and focus restoration.
|
||||
|
||||
Deutsch: Normale Passworteingabe und Sichtbarkeitssteuerung bleiben unmittelbar
|
||||
verfügbar. Der optionale Generator wird erst beim bewussten Öffnen eines
|
||||
aktivierten, bearbeitbaren Felds geladen; Lade- und Fehlerzustände nutzen die
|
||||
gemeinsame Ressourcenanzeige. Ohne "Passwort verwenden" wird kein Kandidat
|
||||
übernommen. Sichere Browser-Zufallszahlen, Richtlinien und öffentliche
|
||||
Schnittstellen bleiben unverändert. Wird die Generierung während des Ladens
|
||||
deaktiviert, öffnet eine verspätete Antwort keinen Dialog.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+4
-3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-core"
|
||||
version = "0.1.14"
|
||||
version = "0.1.46"
|
||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -15,7 +15,7 @@ dependencies = [
|
||||
"fastapi>=0.139,<1",
|
||||
"pydantic>=2,<3",
|
||||
"pydantic-settings>=2,<3",
|
||||
"cryptography>=48.0.1,<50",
|
||||
"cryptography>=50.0.0,<51",
|
||||
"celery>=5,<6",
|
||||
"redis>=5,<6",
|
||||
"alembic>=1,<2",
|
||||
@@ -26,7 +26,7 @@ dependencies = [
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
govoplan_core = ["py.typed"]
|
||||
govoplan_core = ["py.typed", "resources/*.json"]
|
||||
|
||||
[tool.setuptools.data-files]
|
||||
"govoplan_core_runtime" = ["alembic.ini"]
|
||||
@@ -37,6 +37,7 @@ govoplan_core = ["py.typed"]
|
||||
[project.scripts]
|
||||
govoplan-config = "govoplan_core.commands.config:main"
|
||||
govoplan-devserver = "govoplan_core.devserver:main"
|
||||
govoplan-first-admin = "govoplan_core.commands.first_admin:main"
|
||||
govoplan-module-install-plan = "govoplan_core.commands.module_install_plan:main"
|
||||
govoplan-module-installer = "govoplan_core.commands.module_installer:main"
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ class SystemSettings(Base, TimestampMixin):
|
||||
__tablename__ = "core_system_settings"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default="global")
|
||||
default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False)
|
||||
default_locale: Mapped[str] = mapped_column(String(20), default="de", nullable=False)
|
||||
allow_tenant_custom_groups: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
allow_tenant_custom_roles: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
allow_tenant_api_keys: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
@@ -20,4 +20,3 @@ class SystemSettings(Base, TimestampMixin):
|
||||
|
||||
|
||||
__all__ = ["SystemSettings"]
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from govoplan_core.core.appearance import normalize_appearance_overrides
|
||||
|
||||
|
||||
class AuditLogItemResponse(BaseModel):
|
||||
@@ -84,7 +86,7 @@ class TenantInfo(BaseModel):
|
||||
slug: str
|
||||
name: str
|
||||
is_active: bool = True
|
||||
default_locale: str = "en"
|
||||
default_locale: str = "de"
|
||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -93,6 +95,53 @@ class TenantMembershipInfo(TenantInfo):
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class NavigationSeparatorPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: str = Field(pattern=r"^separator:[a-zA-Z0-9_.:-]+$", max_length=255)
|
||||
label: str = Field(default="", max_length=120, pattern=r"^[^\x00-\x1f]*$")
|
||||
|
||||
|
||||
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)
|
||||
separators: list[NavigationSeparatorPayload] | None = Field(default=None, max_length=256)
|
||||
|
||||
|
||||
class AppearanceModeOverrides(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
accent: str
|
||||
accent_foreground: str
|
||||
surface: str
|
||||
surface_foreground: str
|
||||
success: str
|
||||
success_foreground: str
|
||||
info: str
|
||||
info_foreground: str
|
||||
warning: str
|
||||
warning_foreground: str
|
||||
danger: str
|
||||
danger_foreground: str
|
||||
|
||||
|
||||
class AppearanceOverridesDocument(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
schema_version: Literal["1"] = "1"
|
||||
light: AppearanceModeOverrides
|
||||
dark: AppearanceModeOverrides
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_accessibility(self) -> "AppearanceOverridesDocument":
|
||||
normalize_appearance_overrides(self.model_dump(mode="json"))
|
||||
return self
|
||||
|
||||
|
||||
class UserUiPreferences(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
@@ -101,6 +150,20 @@ class UserUiPreferences(BaseModel):
|
||||
reduce_motion: bool = False
|
||||
sticky_section_sidebars: bool = True
|
||||
theme: Literal["system", "light", "dark"] = "system"
|
||||
palette: Literal["default", "civic_blue", "forest", "plum"] | None = None
|
||||
appearance_overrides: AppearanceOverridesDocument | None = None
|
||||
navigation: NavigationPreferencesPayload | None = None
|
||||
|
||||
|
||||
class EffectiveAppearanceInfo(BaseModel):
|
||||
palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
|
||||
source: Literal["user", "tenant", "system", "tenant_lock", "system_lock"] = "system"
|
||||
locked: bool = False
|
||||
system_default_palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
|
||||
tenant_default_palette: Literal["default", "civic_blue", "forest", "plum"] | None = None
|
||||
inherited_palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
|
||||
custom_overrides: AppearanceOverridesDocument | None = None
|
||||
custom_overrides_allowed: bool = False
|
||||
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
@@ -113,9 +176,12 @@ class UserInfo(BaseModel):
|
||||
tenant_display_name: str | None = None
|
||||
is_tenant_admin: bool = False
|
||||
password_reset_required: bool = False
|
||||
required_auth_action: Literal["change_password"] | None = None
|
||||
local_password: bool = False
|
||||
preferred_language: str | None = None
|
||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences)
|
||||
appearance: EffectiveAppearanceInfo = Field(default_factory=EffectiveAppearanceInfo)
|
||||
|
||||
|
||||
class AuthSessionUserInfo(BaseModel):
|
||||
@@ -126,6 +192,8 @@ class AuthSessionUserInfo(BaseModel):
|
||||
tenant_display_name: str | None = None
|
||||
is_tenant_admin: bool = False
|
||||
password_reset_required: bool = False
|
||||
required_auth_action: Literal["change_password"] | None = None
|
||||
local_password: bool = False
|
||||
|
||||
|
||||
class AuthSessionResponse(BaseModel):
|
||||
@@ -210,7 +278,7 @@ class AuthProfileResponse(BaseModel):
|
||||
active_tenant: TenantInfo
|
||||
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
default_language: str = "en"
|
||||
default_language: str = "de"
|
||||
profile_loaded: bool = True
|
||||
|
||||
|
||||
@@ -239,7 +307,7 @@ class LoginResponse(BaseModel):
|
||||
principal: PrincipalContextInfo | None = None
|
||||
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
default_language: str = "en"
|
||||
default_language: str = "de"
|
||||
profile_loaded: bool = True
|
||||
roles_loaded: bool = True
|
||||
groups_loaded: bool = True
|
||||
@@ -257,7 +325,7 @@ class MeResponse(BaseModel):
|
||||
principal: PrincipalContextInfo | None = None
|
||||
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
default_language: str = "en"
|
||||
default_language: str = "de"
|
||||
profile_loaded: bool = True
|
||||
roles_loaded: bool = True
|
||||
groups_loaded: bool = True
|
||||
|
||||
@@ -135,6 +135,9 @@ def get_api_principal(
|
||||
authorization: str | None = Header(default=None),
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
) -> ApiPrincipal:
|
||||
cached = getattr(request.state, "govoplan_api_principal", None)
|
||||
if isinstance(cached, ApiPrincipal):
|
||||
return cached
|
||||
principal = _api_principal_provider_from_request(request).resolve_api_principal(
|
||||
request,
|
||||
session,
|
||||
@@ -143,6 +146,7 @@ def get_api_principal(
|
||||
)
|
||||
if not isinstance(principal, ApiPrincipal):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid API principal")
|
||||
request.state.govoplan_api_principal = principal
|
||||
return principal
|
||||
|
||||
|
||||
|
||||
+688
-113
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
@@ -33,6 +34,10 @@ from govoplan_core.core.module_installer_notifications import (
|
||||
installer_notification_priority,
|
||||
installer_notification_subject,
|
||||
)
|
||||
from govoplan_core.core.runtime_coordination import (
|
||||
bind_process_runtime_identity,
|
||||
runtime_identity,
|
||||
)
|
||||
from govoplan_core.core.module_license import issue_module_license, module_license_diagnostics
|
||||
from govoplan_core.core.module_package_catalog import sign_module_package_catalog, validate_module_package_catalog
|
||||
from govoplan_core.core.module_management import (
|
||||
@@ -107,11 +112,27 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
def main() -> int:
|
||||
args = _build_parser().parse_args()
|
||||
runtime_dir = args.runtime_dir or default_installer_runtime_dir(args.database_url)
|
||||
bind_process_runtime_identity(
|
||||
runtime_identity(
|
||||
settings,
|
||||
software_version=_core_version(),
|
||||
role="installer",
|
||||
)
|
||||
)
|
||||
try:
|
||||
return _dispatch_command(args=args, runtime_dir=runtime_dir)
|
||||
except ModuleInstallerError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
bind_process_runtime_identity(None)
|
||||
|
||||
|
||||
def _core_version() -> str:
|
||||
try:
|
||||
return version("govoplan-core")
|
||||
except PackageNotFoundError:
|
||||
return "development"
|
||||
|
||||
|
||||
def _dispatch_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
|
||||
|
||||
@@ -21,8 +21,13 @@ CAPABILITY_ACCESS_RESOURCE_ACCESS = "access.resourceAccess"
|
||||
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY = "access.semanticDirectory"
|
||||
CAPABILITY_ACCESS_EXPLANATION = "access.explanation"
|
||||
CAPABILITY_ACCESS_TENANT_PROVISIONER = "access.tenantProvisioner"
|
||||
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER = "access.firstAdminProvisioner"
|
||||
CAPABILITY_ACCESS_ADMINISTRATION = "access.administration"
|
||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER = "access.governanceMaterializer"
|
||||
CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1 = "access.governanceProjection.v1"
|
||||
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS = (
|
||||
"policy.access_explanation_subjects"
|
||||
)
|
||||
CAPABILITY_TENANCY_TENANT_RESOLVER = "tenancy.tenantResolver"
|
||||
CAPABILITY_AUDIT_SINK = "audit.sink"
|
||||
CAPABILITY_AUDIT_RECORDER = "audit.recorder"
|
||||
@@ -45,8 +50,10 @@ ACCESS_CAPABILITY_NAMES = frozenset(
|
||||
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
|
||||
CAPABILITY_ACCESS_EXPLANATION,
|
||||
CAPABILITY_ACCESS_TENANT_PROVISIONER,
|
||||
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER,
|
||||
CAPABILITY_ACCESS_ADMINISTRATION,
|
||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
|
||||
CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1,
|
||||
CAPABILITY_TENANCY_TENANT_RESOLVER,
|
||||
CAPABILITY_AUDIT_SINK,
|
||||
CAPABILITY_AUDIT_RECORDER,
|
||||
@@ -175,6 +182,15 @@ class PrincipalRef:
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccessExplanationSubjectDecision:
|
||||
allow_other_users: bool
|
||||
reason: str
|
||||
source: str
|
||||
required_scope: str | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _optional_str(value: object | None) -> str | None:
|
||||
return str(value) if value is not None else None
|
||||
|
||||
@@ -342,6 +358,19 @@ class DevelopmentBootstrapRef:
|
||||
created_api_key: CreatedApiKeyRef | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FirstSystemAdministratorRef:
|
||||
account_id: str
|
||||
email: str
|
||||
display_name: str | None = None
|
||||
membership_id: str | None = None
|
||||
tenant_id: str | None = None
|
||||
|
||||
|
||||
class FirstAdminProvisioningError(RuntimeError):
|
||||
"""Safe, user-facing rejection from the Access enrollment boundary."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantContextSwitchRef:
|
||||
account_id: str
|
||||
@@ -363,6 +392,82 @@ class GovernanceTemplateMaterialization:
|
||||
required: bool = False
|
||||
|
||||
|
||||
GovernanceProjectionOperation = Literal["upsert", "remove"]
|
||||
GovernanceProjectionStatus = Literal[
|
||||
"created",
|
||||
"updated",
|
||||
"unchanged",
|
||||
"removed",
|
||||
"absent",
|
||||
"blocked",
|
||||
"failed",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GovernanceProjectionCommand:
|
||||
"""Stable Access-owned input for one governance assignment projection."""
|
||||
|
||||
assignment_id: str
|
||||
operation: GovernanceProjectionOperation
|
||||
template: GovernanceTemplateMaterialization
|
||||
provenance: Mapping[str, str] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.assignment_id or len(self.assignment_id) > 255:
|
||||
raise ValueError("Governance projection assignment ids must contain at most 255 characters.")
|
||||
if len(self.provenance) > 20:
|
||||
raise ValueError("Governance projection provenance supports at most 20 entries.")
|
||||
for key, value in self.provenance.items():
|
||||
if not key or len(key) > 100 or len(value) > 500:
|
||||
raise ValueError("Governance projection provenance entries exceed their bounds.")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GovernanceProjectionBatch:
|
||||
"""Versioned, bounded reconciliation request independent of Admin internals."""
|
||||
|
||||
operation_id: str
|
||||
commands: tuple[GovernanceProjectionCommand, ...]
|
||||
version: Literal["1"] = "1"
|
||||
dry_run: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.operation_id or len(self.operation_id) > 255:
|
||||
raise ValueError("Governance projection operation ids must contain at most 255 characters.")
|
||||
if not self.commands or len(self.commands) > 500:
|
||||
raise ValueError("Governance projection batches must contain between 1 and 500 commands.")
|
||||
assignment_ids = [command.assignment_id for command in self.commands]
|
||||
if len(assignment_ids) != len(set(assignment_ids)):
|
||||
raise ValueError("Governance projection assignment ids must be unique within a batch.")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GovernanceProjectionOutcome:
|
||||
assignment_id: str
|
||||
template_id: str
|
||||
tenant_id: str
|
||||
kind: Literal["group", "role"]
|
||||
operation: GovernanceProjectionOperation
|
||||
status: GovernanceProjectionStatus
|
||||
resource_id: str | None = None
|
||||
blocker_codes: tuple[str, ...] = ()
|
||||
message: str | None = None
|
||||
provenance: Mapping[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GovernanceProjectionResult:
|
||||
operation_id: str
|
||||
outcomes: tuple[GovernanceProjectionOutcome, ...]
|
||||
version: Literal["1"] = "1"
|
||||
dry_run: bool = False
|
||||
|
||||
@property
|
||||
def blocked(self) -> tuple[GovernanceProjectionOutcome, ...]:
|
||||
return tuple(item for item in self.outcomes if item.status in {"blocked", "failed"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuditEvent:
|
||||
event_type: str
|
||||
@@ -549,6 +654,18 @@ class AccessExplanationService(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AccessExplanationSubjectPolicy(Protocol):
|
||||
def decide_subject_selection(
|
||||
self,
|
||||
session: object,
|
||||
principal: PrincipalRef,
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> AccessExplanationSubjectDecision:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TenantAccessProvisioner(Protocol):
|
||||
def ensure_default_roles(self, session: object, tenant: object | None = None) -> Mapping[str, object]:
|
||||
@@ -579,6 +696,25 @@ class TenantAccessProvisioner(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class FirstAdminProvisioner(Protocol):
|
||||
"""Narrow Access boundary used only by the production bootstrap flow."""
|
||||
|
||||
def has_durable_system_administrator(self, session: object) -> bool:
|
||||
...
|
||||
|
||||
def create_first_system_administrator(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant: object,
|
||||
email: str,
|
||||
display_name: str | None,
|
||||
password: str,
|
||||
) -> FirstSystemAdministratorRef:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AccessAdministration(Protocol):
|
||||
def tenant_counts(self, session: object, tenant_id: str) -> Mapping[str, int]:
|
||||
@@ -635,6 +771,18 @@ class AccessGovernanceMaterializer(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AccessGovernanceProjectionV1(Protocol):
|
||||
"""Bulk reconciliation boundary for Admin-owned governance assignments."""
|
||||
|
||||
def reconcile(
|
||||
self,
|
||||
session: object,
|
||||
batch: GovernanceProjectionBatch,
|
||||
) -> GovernanceProjectionResult:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AuditSink(Protocol):
|
||||
def record(self, event: AuditEvent) -> None:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -31,6 +31,13 @@ ActionReversibility = Literal[
|
||||
"corrective_only",
|
||||
"irreversible",
|
||||
]
|
||||
ActionRecoveryMode = Literal[
|
||||
"atomic",
|
||||
"compensation",
|
||||
"snapshot_restore",
|
||||
"forward_recovery",
|
||||
"irreversible",
|
||||
]
|
||||
ActionExecutionState = Literal[
|
||||
"pending",
|
||||
"running",
|
||||
@@ -87,6 +94,10 @@ class ActionDefinition:
|
||||
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:
|
||||
@@ -96,12 +107,24 @@ class ActionDefinition:
|
||||
_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)
|
||||
@@ -384,6 +407,7 @@ __all__ = [
|
||||
"AutomationPrincipalResolution",
|
||||
"AutomationSubjectKind",
|
||||
"ActionPreview",
|
||||
"ActionRecoveryMode",
|
||||
"ActionReversibility",
|
||||
"ActionRiskLevel",
|
||||
"EffectDefinition",
|
||||
|
||||
@@ -45,6 +45,15 @@ class CalendarEventRef:
|
||||
outbox_operation_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CalendarEventReleaseRef:
|
||||
event_id: str
|
||||
accepted: bool = True
|
||||
already_released: bool = False
|
||||
external_state: str = "local_released"
|
||||
outbox_operation_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CalendarInvitationAttendeeRequest:
|
||||
address: str
|
||||
@@ -156,6 +165,27 @@ class CalendarSchedulingProvider(Protocol):
|
||||
) -> CalendarEventRef:
|
||||
...
|
||||
|
||||
def promote_event(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
event_id: str,
|
||||
request: CalendarEventRequest,
|
||||
) -> CalendarEventRef:
|
||||
...
|
||||
|
||||
def release_event(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
event_id: str,
|
||||
) -> CalendarEventReleaseRef:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CalendarOutboxProvider(Protocol):
|
||||
|
||||
@@ -3,14 +3,29 @@ from __future__ import annotations
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Protocol, runtime_checkable
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT = "campaigns.mailPolicyContext"
|
||||
CAPABILITY_CAMPAIGNS_ACCESS = "campaigns.access"
|
||||
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT = "campaigns.policyContext"
|
||||
CAPABILITY_CAMPAIGNS_DELIVERY_TASKS = "campaigns.deliveryTasks"
|
||||
CAPABILITY_CAMPAIGNS_SCHEDULES = "campaigns.schedules"
|
||||
CAPABILITY_CAMPAIGNS_RETENTION = "campaigns.retention"
|
||||
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION = "campaigns.workOrchestration"
|
||||
|
||||
CampaignWorkAssigneeKind = Literal[
|
||||
"account",
|
||||
"group",
|
||||
"organization_function",
|
||||
]
|
||||
CampaignWorkHandoffStatus = Literal[
|
||||
"open",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"rejected",
|
||||
"cancelled",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -31,6 +46,88 @@ class CampaignPolicyContext:
|
||||
settings: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignWorkHandoffRequest:
|
||||
"""Typed request used by Workflow to open accountable Campaign work."""
|
||||
|
||||
tenant_id: str
|
||||
idempotency_key: str
|
||||
purpose: str
|
||||
assignee_kind: CampaignWorkAssigneeKind
|
||||
assignee_id: str
|
||||
campaign_id: str | None = None
|
||||
create_external_id: str | None = None
|
||||
create_name: str | None = None
|
||||
create_description: str | None = None
|
||||
expected_campaign_revision: int | None = None
|
||||
due_at: datetime | None = None
|
||||
mirror_to_tasks: bool = True
|
||||
correlation_id: str | None = None
|
||||
workflow_instance_id: str | None = None
|
||||
workflow_step_id: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for value, label in (
|
||||
(self.tenant_id, "Campaign hand-off tenant"),
|
||||
(self.idempotency_key, "Campaign hand-off idempotency key"),
|
||||
(self.purpose, "Campaign hand-off purpose"),
|
||||
(self.assignee_id, "Campaign hand-off assignee"),
|
||||
):
|
||||
if not value.strip():
|
||||
raise ValueError(f"{label} is required")
|
||||
references_existing = bool(self.campaign_id and self.campaign_id.strip())
|
||||
creates_new = bool(
|
||||
self.create_external_id
|
||||
and self.create_external_id.strip()
|
||||
and self.create_name
|
||||
and self.create_name.strip()
|
||||
)
|
||||
if references_existing == creates_new:
|
||||
raise ValueError(
|
||||
"Campaign hand-offs must either reference one campaign or "
|
||||
"declare one new campaign."
|
||||
)
|
||||
if self.expected_campaign_revision is not None and (
|
||||
self.expected_campaign_revision < 1
|
||||
):
|
||||
raise ValueError("Expected Campaign revisions start at one")
|
||||
if self.due_at is not None and self.due_at.tzinfo is None:
|
||||
raise ValueError("Campaign hand-off due dates require a timezone")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignWorkHandoffRef:
|
||||
"""Stable, revision-bearing reference returned to the Workflow instance."""
|
||||
|
||||
tenant_id: str
|
||||
campaign_id: str
|
||||
campaign_version_id: str
|
||||
campaign_revision: int
|
||||
assignment_id: str
|
||||
assignment_revision: int
|
||||
status: CampaignWorkHandoffStatus
|
||||
action_url: str
|
||||
campaign_ref: str
|
||||
assignment_ref: str
|
||||
event_type: str = "campaign.work.changed"
|
||||
replayed: bool = False
|
||||
optional_capabilities: Mapping[str, bool] = field(default_factory=dict)
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignWorkHandoffInspection:
|
||||
"""Current authorization and revision check before Workflow continuation."""
|
||||
|
||||
allowed: bool
|
||||
status: CampaignWorkHandoffStatus | None = None
|
||||
assignment_revision: int | None = None
|
||||
action_url: str | None = None
|
||||
assignment_ref: str | None = None
|
||||
reason: str | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CampaignMailPolicyContextProvider(Protocol):
|
||||
def get_campaign_mail_policy_context(
|
||||
@@ -95,6 +192,9 @@ class CampaignPolicyContextProvider(Protocol):
|
||||
|
||||
@runtime_checkable
|
||||
class CampaignDeliveryTaskProvider(Protocol):
|
||||
def tenant_id_for_job(self, session: object, *, job_id: str) -> str | None:
|
||||
...
|
||||
|
||||
def send_campaign_job(self, session: object, *, job_id: str, enqueue_imap_task: bool = True) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
@@ -102,6 +202,21 @@ class CampaignDeliveryTaskProvider(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CampaignScheduleProvider(Protocol):
|
||||
"""Durable boundary for due manual drafts and governed autonomous occurrences."""
|
||||
|
||||
def dispatch_due(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
limit: int = 50,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CampaignRetentionProvider(Protocol):
|
||||
def apply_retention(
|
||||
@@ -113,3 +228,45 @@ class CampaignRetentionProvider(Protocol):
|
||||
policy_for_campaign_id: Callable[[str | None], object],
|
||||
) -> Mapping[str, Mapping[str, int]]:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CampaignWorkOrchestrationProvider(Protocol):
|
||||
"""Optional Campaign boundary for durable Workflow-owned hand-offs."""
|
||||
|
||||
def prepare_handoff(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: CampaignWorkHandoffRequest,
|
||||
) -> CampaignWorkHandoffRef:
|
||||
...
|
||||
|
||||
def inspect_handoff(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
assignment_id: str,
|
||||
expected_revision: int | None = None,
|
||||
) -> CampaignWorkHandoffInspection:
|
||||
...
|
||||
|
||||
|
||||
def campaign_work_orchestration_provider(
|
||||
registry: object | None,
|
||||
) -> CampaignWorkOrchestrationProvider | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not registry.has_capability(CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION)
|
||||
):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION)
|
||||
return (
|
||||
capability
|
||||
if isinstance(capability, CampaignWorkOrchestrationProvider)
|
||||
else None
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import heapq
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Iterable, Mapping, Sequence
|
||||
@@ -436,10 +437,33 @@ def _merge_list(
|
||||
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:
|
||||
secondary_order = current_order if order_source is local_order else local_order
|
||||
# Keep the chosen side's order and both sides' insertion anchors. Appending
|
||||
# missing IDs would silently relocate an insertion during a disjoint edit.
|
||||
# An incompatible reorder/insertion cycle is an explicit conflict.
|
||||
edges: dict[str, set[str]] = {identity: set() for identity in result_by_id}
|
||||
incoming = dict.fromkeys(result_by_id, 0)
|
||||
for order, insertions_only in ((order_source, False), (secondary_order, True)):
|
||||
selected = [identity for identity in order if identity in result_by_id]
|
||||
for left, right in zip(selected, selected[1:]):
|
||||
if insertions_only and left in base_by_id and right in base_by_id:
|
||||
continue
|
||||
if right not in edges[left]:
|
||||
edges[left].add(right)
|
||||
incoming[right] += 1
|
||||
priority = {identity: index for index, identity in enumerate(identities)}
|
||||
ready = [(priority[identity], identity) for identity, count in incoming.items() if count == 0]
|
||||
heapq.heapify(ready)
|
||||
merged_order: list[str] = []
|
||||
while ready:
|
||||
_, identity = heapq.heappop(ready)
|
||||
merged_order.append(identity)
|
||||
for following in edges[identity]:
|
||||
incoming[following] -= 1
|
||||
if incoming[following] == 0:
|
||||
heapq.heappush(ready, (priority[following], following))
|
||||
if len(merged_order) != len(result_by_id):
|
||||
return _conflict(path, "collection_reorder", base, local, current)
|
||||
return ThreeWayMergeResult(
|
||||
value=[result_by_id[identity] for identity in merged_order],
|
||||
conflicts=conflicts,
|
||||
|
||||
@@ -371,6 +371,24 @@ def _approval_count(request: dict[str, Any]) -> int:
|
||||
|
||||
|
||||
def _sanitize_value(key: str, value: object) -> object:
|
||||
if key == "campaign_archive_encryption_policy":
|
||||
if not isinstance(value, dict):
|
||||
return "<redacted>"
|
||||
# These are format/channel names, never passwords. Preserve only the
|
||||
# exact public enum lists so rollback history remains useful without
|
||||
# exempting arbitrary password-named fields from secret redaction.
|
||||
allowed_values = {
|
||||
"allowed_password_encryption_methods": frozenset({"aes", "zip_standard"}),
|
||||
"allowed_password_delivery_channels": frozenset({"separate_mail", "sms", "letter", "phone", "in_person"}),
|
||||
}
|
||||
return {
|
||||
name: list(items)
|
||||
if name in allowed_values
|
||||
and isinstance(items, list)
|
||||
and all(isinstance(item, str) and item in allowed_values[name] for item in items)
|
||||
else "<redacted>"
|
||||
for name, items in value.items()
|
||||
}
|
||||
field = classify_configuration_field(key)
|
||||
if field is not None and field.secret_handling in {"reference_only", "env_only"}:
|
||||
return _redact_secrets(value)
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
import base64
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from importlib.metadata import PackageNotFoundError, version as package_version
|
||||
from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
@@ -28,6 +30,9 @@ from govoplan_core.core.external_references import (
|
||||
SourceAuthorityMode,
|
||||
integration_maturity_rank,
|
||||
)
|
||||
from govoplan_core.core.infrastructure_capabilities import (
|
||||
InfrastructureCapabilityReceipt,
|
||||
)
|
||||
from govoplan_core.security.http_fetch import fetch_http_text
|
||||
|
||||
|
||||
@@ -35,6 +40,12 @@ CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider"
|
||||
|
||||
DiagnosticSeverity = Literal["blocker", "warning", "info"]
|
||||
PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"]
|
||||
ConfigurationRollbackStatus = Literal[
|
||||
"blocked_before_apply",
|
||||
"not_required",
|
||||
"database_restore_required",
|
||||
"partial_apply_requires_recovery",
|
||||
]
|
||||
ConfigurationPackageClass = Literal[
|
||||
"reference",
|
||||
"product",
|
||||
@@ -441,6 +452,9 @@ class ConfigurationPreflightContext:
|
||||
default_factory=dict
|
||||
)
|
||||
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)
|
||||
@@ -455,6 +469,21 @@ class ConfigurationApplyResult:
|
||||
diagnostics: tuple[ConfigurationDiagnostic, ...] = ()
|
||||
created_refs: Mapping[str, str] = field(default_factory=dict)
|
||||
updated_refs: Mapping[str, str] = field(default_factory=dict)
|
||||
rollback: "ConfigurationRollbackState | None" = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationRollbackState:
|
||||
status: ConfigurationRollbackStatus
|
||||
summary: str
|
||||
recovery_action: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"status": self.status,
|
||||
"summary": self.summary,
|
||||
"recovery_action": self.recovery_action,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -465,11 +494,40 @@ class ConfigurationExportSelection:
|
||||
object_refs: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationExportProvenance:
|
||||
exported_at: str
|
||||
source_core_version: str
|
||||
module_versions: Mapping[str, str]
|
||||
tenant_id: str | None
|
||||
exporter_id: str | None
|
||||
scopes: tuple[str, ...] = ()
|
||||
module_ids: tuple[str, ...] = ()
|
||||
object_refs: tuple[str, ...] = ()
|
||||
redacted_secret_keys: tuple[str, ...] = ()
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"exported_at": self.exported_at,
|
||||
"source_core_version": self.source_core_version,
|
||||
"module_versions": dict(self.module_versions),
|
||||
"tenant_id": self.tenant_id,
|
||||
"exporter_id": self.exporter_id,
|
||||
"selection": {
|
||||
"scopes": list(self.scopes),
|
||||
"module_ids": list(self.module_ids),
|
||||
"object_refs": list(self.object_refs),
|
||||
},
|
||||
"redacted_secret_keys": list(self.redacted_secret_keys),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationExportResult:
|
||||
fragments: tuple[ConfigurationPackageFragment, ...] = ()
|
||||
data_requirements: tuple[ConfigurationRequiredData, ...] = ()
|
||||
diagnostics: tuple[ConfigurationDiagnostic, ...] = ()
|
||||
provenance: ConfigurationExportProvenance | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -502,6 +560,7 @@ def dry_run_configuration_package(
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
required_data: list[ConfigurationRequiredData] = []
|
||||
plan: list[ConfigurationPlanItem] = []
|
||||
declared_data: dict[str, ConfigurationRequiredData] = {}
|
||||
|
||||
diagnostics.extend(_module_requirement_diagnostics(manifest, context))
|
||||
diagnostics.extend(_capability_requirement_diagnostics(manifest, context))
|
||||
@@ -509,6 +568,7 @@ def dry_run_configuration_package(
|
||||
for item in manifest.data_requirements:
|
||||
requirement = ConfigurationRequiredData.from_mapping(item)
|
||||
required_data.append(requirement)
|
||||
declared_data[requirement.key] = requirement
|
||||
if requirement.required and requirement.key not in context.supplied_data:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
@@ -519,6 +579,25 @@ def dry_run_configuration_package(
|
||||
))
|
||||
|
||||
for fragment in manifest.fragments:
|
||||
data_ref_diagnostics = _fragment_data_reference_diagnostics(
|
||||
fragment,
|
||||
declared_data=declared_data,
|
||||
supplied_data=context.supplied_data,
|
||||
)
|
||||
if data_ref_diagnostics:
|
||||
diagnostics.extend(data_ref_diagnostics)
|
||||
plan.append(ConfigurationPlanItem(
|
||||
action="blocked",
|
||||
module_id=fragment.module_id,
|
||||
fragment_type=fragment.fragment_type,
|
||||
fragment_id=fragment.fragment_id,
|
||||
summary="Fragment needs declared deployment data before provider preflight.",
|
||||
))
|
||||
continue
|
||||
resolved_fragment = _resolve_fragment_data_references(
|
||||
fragment,
|
||||
context.supplied_data,
|
||||
)
|
||||
provider = provider_map.get(fragment.module_id)
|
||||
if provider is None:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
@@ -544,7 +623,7 @@ def dry_run_configuration_package(
|
||||
plan.append(ConfigurationPlanItem(action="blocked", module_id=fragment.module_id, fragment_type=fragment.fragment_type, fragment_id=fragment.fragment_id, summary="Fragment type is unsupported."))
|
||||
continue
|
||||
try:
|
||||
result = provider.preflight(fragment, context)
|
||||
result = provider.preflight(resolved_fragment, context)
|
||||
except Exception as exc:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
@@ -586,29 +665,54 @@ def apply_configuration_package(
|
||||
apply_context = ConfigurationPreflightContext(
|
||||
tenant_id=context.tenant_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,
|
||||
installed_modules=context.installed_modules,
|
||||
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,
|
||||
)
|
||||
preflight = dry_run_configuration_package(manifest, providers, apply_context)
|
||||
blockers = [item for item in preflight.diagnostics if item.severity == "blocker"]
|
||||
if blockers:
|
||||
return ConfigurationApplyResult(diagnostics=tuple(blockers))
|
||||
return ConfigurationApplyResult(
|
||||
diagnostics=tuple(blockers),
|
||||
rollback=ConfigurationRollbackState(
|
||||
status="blocked_before_apply",
|
||||
summary="No provider changes were attempted because package preflight is blocked.",
|
||||
),
|
||||
)
|
||||
provider_map = _configuration_provider_map(providers)
|
||||
diagnostics: list[ConfigurationDiagnostic] = list(preflight.diagnostics)
|
||||
created_refs: dict[str, str] = {}
|
||||
updated_refs: dict[str, str] = {}
|
||||
stopped_after_blocker = False
|
||||
for fragment in manifest.fragments:
|
||||
provider = provider_map[fragment.module_id]
|
||||
resolved_fragment = _resolve_fragment_data_references(
|
||||
fragment,
|
||||
apply_context.supplied_data,
|
||||
)
|
||||
try:
|
||||
result = provider.apply(fragment, apply_context.supplied_data, apply_context)
|
||||
result = provider.apply(
|
||||
resolved_fragment,
|
||||
apply_context.supplied_data,
|
||||
apply_context,
|
||||
)
|
||||
diagnostics.extend(result.diagnostics)
|
||||
created_refs.update(result.created_refs)
|
||||
updated_refs.update(result.updated_refs)
|
||||
diagnostics.extend(provider.health(result, apply_context))
|
||||
health_diagnostics = provider.health(result, apply_context)
|
||||
diagnostics.extend(health_diagnostics)
|
||||
if any(
|
||||
item.severity == "blocker"
|
||||
for item in (*result.diagnostics, *health_diagnostics)
|
||||
):
|
||||
stopped_after_blocker = True
|
||||
break
|
||||
except Exception as exc:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
@@ -618,10 +722,36 @@ def apply_configuration_package(
|
||||
object_ref=fragment.fragment_id or fragment.fragment_type,
|
||||
resolution="Stop the import, keep previous configuration, and inspect provider logs.",
|
||||
))
|
||||
stopped_after_blocker = True
|
||||
break
|
||||
changed = bool(created_refs or updated_refs)
|
||||
if stopped_after_blocker and changed:
|
||||
rollback = ConfigurationRollbackState(
|
||||
status="partial_apply_requires_recovery",
|
||||
summary="At least one provider committed changes before a later provider blocked the package.",
|
||||
recovery_action="Restore the reviewed pre-apply database snapshot or use module-owned compensation where explicitly supported.",
|
||||
)
|
||||
elif stopped_after_blocker:
|
||||
rollback = ConfigurationRollbackState(
|
||||
status="blocked_before_apply",
|
||||
summary="The first provider blocked before any configuration reference was created or updated.",
|
||||
)
|
||||
elif changed:
|
||||
rollback = ConfigurationRollbackState(
|
||||
status="database_restore_required",
|
||||
summary="The package changed provider-owned configuration; generic cross-module compensation is not available.",
|
||||
recovery_action="Retain the pre-apply database snapshot until verification is complete; restore it if the package must be rolled back.",
|
||||
)
|
||||
else:
|
||||
rollback = ConfigurationRollbackState(
|
||||
status="not_required",
|
||||
summary="All package fragments were no-ops, so no rollback action is required.",
|
||||
)
|
||||
return ConfigurationApplyResult(
|
||||
diagnostics=tuple(_dedupe_diagnostics(diagnostics)),
|
||||
created_refs=created_refs,
|
||||
updated_refs=updated_refs,
|
||||
rollback=rollback,
|
||||
)
|
||||
|
||||
|
||||
@@ -660,10 +790,29 @@ def export_configuration_package(
|
||||
fragments.extend(result.fragments)
|
||||
data_requirements.extend(result.data_requirements)
|
||||
diagnostics.extend(result.diagnostics)
|
||||
deduped_required_data = tuple(_dedupe_required_data(data_requirements))
|
||||
provenance = ConfigurationExportProvenance(
|
||||
exported_at=datetime.now(UTC).isoformat(),
|
||||
source_core_version=_installed_core_version(),
|
||||
module_versions={
|
||||
module_id: context.installed_modules[module_id]
|
||||
for module_id in sorted(set(module_ids))
|
||||
if module_id in context.installed_modules
|
||||
},
|
||||
tenant_id=selection.tenant_id,
|
||||
exporter_id=context.operator_user_id,
|
||||
scopes=selection.scopes,
|
||||
module_ids=tuple(module_ids),
|
||||
object_refs=selection.object_refs,
|
||||
redacted_secret_keys=tuple(
|
||||
sorted(item.key for item in deduped_required_data if item.secret)
|
||||
),
|
||||
)
|
||||
return ConfigurationExportResult(
|
||||
fragments=tuple(fragments),
|
||||
data_requirements=tuple(_dedupe_required_data(data_requirements)),
|
||||
data_requirements=deduped_required_data,
|
||||
diagnostics=tuple(_dedupe_diagnostics(diagnostics)),
|
||||
provenance=provenance,
|
||||
)
|
||||
|
||||
|
||||
@@ -1274,6 +1423,103 @@ def _dedupe_required_data(items: Sequence[ConfigurationRequiredData]) -> list[Co
|
||||
return result
|
||||
|
||||
|
||||
def _fragment_data_reference_diagnostics(
|
||||
fragment: ConfigurationPackageFragment,
|
||||
*,
|
||||
declared_data: Mapping[str, ConfigurationRequiredData],
|
||||
supplied_data: Mapping[str, Any],
|
||||
) -> list[ConfigurationDiagnostic]:
|
||||
references: set[str] = set()
|
||||
invalid = _collect_fragment_data_references(fragment.payload, references)
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
object_ref = fragment.fragment_id or fragment.fragment_type
|
||||
if invalid:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="fragment_data_reference_invalid",
|
||||
message="Configuration fragment data references must be objects containing only a non-empty $data key.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=object_ref,
|
||||
resolution="Replace malformed references with {\"$data\": \"declared_requirement_key\"}.",
|
||||
))
|
||||
for key in sorted(references - set(declared_data)):
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="fragment_data_reference_undeclared",
|
||||
message=f"Configuration fragment references undeclared operator data {key!r}.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=key,
|
||||
resolution="Declare the key in package data_requirements before using it in a fragment.",
|
||||
))
|
||||
for key in sorted(references & set(declared_data)):
|
||||
if key in supplied_data:
|
||||
continue
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="fragment_data_reference_missing",
|
||||
message=f"Configuration fragment needs operator data {declared_data[key].label!r} before provider preflight.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=key,
|
||||
resolution="Provide the value in the generated configuration package form.",
|
||||
))
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _collect_fragment_data_references(value: object, references: set[str]) -> bool:
|
||||
invalid = False
|
||||
if isinstance(value, Mapping):
|
||||
if "$data" in value:
|
||||
key = value.get("$data")
|
||||
if len(value) != 1 or not isinstance(key, str) or not key.strip():
|
||||
return True
|
||||
references.add(key.strip())
|
||||
return False
|
||||
for item in value.values():
|
||||
invalid = _collect_fragment_data_references(item, references) or invalid
|
||||
elif isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
||||
for item in value:
|
||||
invalid = _collect_fragment_data_references(item, references) or invalid
|
||||
return invalid
|
||||
|
||||
|
||||
def _resolve_fragment_data_references(
|
||||
fragment: ConfigurationPackageFragment,
|
||||
supplied_data: Mapping[str, Any],
|
||||
) -> ConfigurationPackageFragment:
|
||||
payload = _resolve_data_reference_value(fragment.payload, supplied_data)
|
||||
if not isinstance(payload, Mapping):
|
||||
raise ValueError("Resolved configuration fragment payload must remain an object.")
|
||||
return ConfigurationPackageFragment(
|
||||
module_id=fragment.module_id,
|
||||
fragment_type=fragment.fragment_type,
|
||||
fragment_id=fragment.fragment_id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_data_reference_value(value: object, supplied_data: Mapping[str, Any]) -> object:
|
||||
if isinstance(value, Mapping):
|
||||
if set(value) == {"$data"}:
|
||||
key = value.get("$data")
|
||||
if not isinstance(key, str) or key not in supplied_data:
|
||||
raise ValueError("Configuration fragment contains an unresolved $data reference.")
|
||||
return supplied_data[key]
|
||||
return {
|
||||
str(key): _resolve_data_reference_value(item, supplied_data)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
||||
return [_resolve_data_reference_value(item, supplied_data) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _installed_core_version() -> str:
|
||||
try:
|
||||
return package_version("govoplan-core")
|
||||
except PackageNotFoundError:
|
||||
return "workspace"
|
||||
|
||||
|
||||
def _catalog_source(path: Path | str | None) -> Path | str | None:
|
||||
if path is not None:
|
||||
return path if isinstance(path, str) and _is_http_url(path) else Path(path).expanduser()
|
||||
|
||||
@@ -107,6 +107,20 @@ class _ConfigurationChangeSafetyState:
|
||||
|
||||
|
||||
_CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||
ConfigurationFieldSafety(
|
||||
key="campaign_delivery_policy.system", label="System Campaign synchronous delivery limit",
|
||||
owner_module="campaigns", scope="system", storage="system_settings", ui_managed=True,
|
||||
risk="medium", required_scopes=("system:settings:write",),
|
||||
audit_event="campaign.delivery_policy_updated", rollback_history_required=True,
|
||||
notes="A bounded 0–500 recipient-job maximum for one interactive Send now request. Explicit deployment ceilings remain authoritative; saving never delivers mail or changes review evidence.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="campaign_delivery_policy.tenant", label="Tenant Campaign synchronous delivery limit",
|
||||
owner_module="campaigns", scope="tenant", storage="tenant_settings", ui_managed=True,
|
||||
risk="medium", required_scopes=("admin:policies:write",),
|
||||
audit_event="campaign.delivery_policy_updated", rollback_history_required=True,
|
||||
notes="Tenant policy may only narrow the inherited system/deployment recipient-job maximum; clearing an override restores inheritance. Changes retain before/after history.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="module_management.desired_enabled",
|
||||
label="Enabled modules",
|
||||
@@ -171,6 +185,21 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||
rollback_history_required=True,
|
||||
notes="Maintenance mode controls platform availability and gates dangerous operations.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="campaign_archive_encryption_policy",
|
||||
label="Campaign archive encryption policy",
|
||||
owner_module="policy",
|
||||
scope="system",
|
||||
storage="policy_overrides",
|
||||
ui_managed=True,
|
||||
risk="high",
|
||||
required_scopes=("system:settings:write", "admin:policies:write"),
|
||||
validation_required=True,
|
||||
policy_explanation_required=True,
|
||||
audit_event="campaign_archive_encryption_policy.updated",
|
||||
rollback_history_required=True,
|
||||
notes="Explicit system ceiling for Campaign archive methods and separate password-delivery channels. Policy validates allowed values and retains before/after history; lower scopes may only narrow. Legacy use additionally requires the dedicated Campaign permission and reasoned weak-encryption acknowledgement, so saving policy alone never enables or sends an archive.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="privacy_retention_policy",
|
||||
label="Privacy retention policy",
|
||||
|
||||
@@ -52,6 +52,7 @@ class DataflowDatasetRequest:
|
||||
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)
|
||||
@@ -178,6 +179,7 @@ class DataflowTriggerDispatcher(Protocol):
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
limit: int = 50,
|
||||
) -> Mapping[str, object]:
|
||||
@@ -204,6 +206,7 @@ class DataflowRunWorker(Protocol):
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
limit: int = 10,
|
||||
worker_id: str | None = None,
|
||||
@@ -214,6 +217,7 @@ class DataflowRunWorker(Protocol):
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
limit: int = 500,
|
||||
) -> Mapping[str, object]:
|
||||
|
||||
@@ -5,16 +5,28 @@ 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,
|
||||
TabularCsvSource,
|
||||
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[
|
||||
@@ -29,6 +41,12 @@ DatasourceKind = Literal[
|
||||
]
|
||||
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):
|
||||
@@ -56,6 +74,7 @@ class DatasourceField:
|
||||
name: str
|
||||
data_type: str
|
||||
nullable: bool = True
|
||||
classification: str = "internal"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -76,11 +95,15 @@ class DatasourceGovernance:
|
||||
classification: str = "internal"
|
||||
privacy_profile_ref: str | None = None
|
||||
retention_policy_ref: str | None = None
|
||||
access_policy_ref: str | None = None
|
||||
visibility_policy: Mapping[str, object] = field(default_factory=dict)
|
||||
hold_refs: tuple[str, ...] = ()
|
||||
publication_state: str = "draft"
|
||||
transfer_agreement_ref: str | None = None
|
||||
freshness_policy: Mapping[str, object] = field(default_factory=dict)
|
||||
quality_policy: Mapping[str, object] = field(default_factory=dict)
|
||||
approval_policy: Mapping[str, object] = field(default_factory=dict)
|
||||
retention_policy: Mapping[str, object] = field(default_factory=dict)
|
||||
known_limits: tuple[str, ...] = ()
|
||||
correction_procedure_ref: str | None = None
|
||||
affected_refs: tuple[str, ...] = ()
|
||||
@@ -146,6 +169,12 @@ class DatasourceGovernance:
|
||||
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(
|
||||
@@ -153,6 +182,8 @@ class DatasourceGovernance:
|
||||
),
|
||||
freshness_policy=_governance_mapping(source.get("freshness_policy")),
|
||||
quality_policy=_governance_mapping(source.get("quality_policy")),
|
||||
approval_policy=_governance_mapping(source.get("approval_policy")),
|
||||
retention_policy=_governance_mapping(source.get("retention_policy")),
|
||||
known_limits=_governance_texts(source.get("known_limits")),
|
||||
correction_procedure_ref=_optional_governance_text(
|
||||
source.get("correction_procedure_ref")
|
||||
@@ -177,11 +208,15 @@ class DatasourceGovernance:
|
||||
"classification": self.classification,
|
||||
"privacy_profile_ref": self.privacy_profile_ref,
|
||||
"retention_policy_ref": self.retention_policy_ref,
|
||||
"access_policy_ref": self.access_policy_ref,
|
||||
"visibility_policy": dict(self.visibility_policy),
|
||||
"hold_refs": list(self.hold_refs),
|
||||
"publication_state": self.publication_state,
|
||||
"transfer_agreement_ref": self.transfer_agreement_ref,
|
||||
"freshness_policy": dict(self.freshness_policy),
|
||||
"quality_policy": dict(self.quality_policy),
|
||||
"approval_policy": dict(self.approval_policy),
|
||||
"retention_policy": dict(self.retention_policy),
|
||||
"known_limits": list(self.known_limits),
|
||||
"correction_procedure_ref": self.correction_procedure_ref,
|
||||
"affected_refs": list(self.affected_refs),
|
||||
@@ -189,6 +224,39 @@ class DatasourceGovernance:
|
||||
}
|
||||
|
||||
|
||||
@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
|
||||
@@ -228,6 +296,8 @@ class DatasourceMaterialization:
|
||||
frozen_label: str | None = None
|
||||
source_timestamp: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
disposed_at: datetime | None = None
|
||||
disposition: Mapping[str, object] = field(default_factory=dict)
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
governance: DatasourceGovernance = field(default_factory=DatasourceGovernance)
|
||||
@@ -248,6 +318,7 @@ class DatasourceStage:
|
||||
row_count: int | None = None
|
||||
byte_count: int | None = None
|
||||
validation: Mapping[str, object] = field(default_factory=dict)
|
||||
approval: Mapping[str, object] = field(default_factory=dict)
|
||||
created_at: datetime | None = None
|
||||
promoted_at: datetime | None = None
|
||||
promoted_materialization_ref: str | None = None
|
||||
@@ -265,6 +336,8 @@ class DatasourceReadRequest:
|
||||
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)
|
||||
@@ -274,6 +347,12 @@ class DatasourceReadResult:
|
||||
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)
|
||||
@@ -291,6 +370,67 @@ class DatasourceStageInput:
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
governance: DatasourceGovernance | None = None
|
||||
csv_source: TabularCsvSource | 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)
|
||||
@@ -298,7 +438,8 @@ class DatasourcePublicationRequest:
|
||||
producer_module: str
|
||||
producer_run_ref: str
|
||||
idempotency_key: str
|
||||
rows: tuple[Mapping[str, object], ...]
|
||||
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
|
||||
@@ -315,7 +456,7 @@ class DatasourcePublicationRequest:
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourcePublicationResult:
|
||||
ref: str
|
||||
status: str
|
||||
status: DatasourcePublicationStatus
|
||||
datasource: DatasourceDescriptor
|
||||
materialization: DatasourceMaterialization
|
||||
replayed: bool = False
|
||||
@@ -339,6 +480,9 @@ class DatasourceOrigin:
|
||||
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)
|
||||
@@ -348,6 +492,8 @@ class DatasourceOriginReadRequest:
|
||||
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)
|
||||
@@ -356,6 +502,12 @@ class DatasourceOriginReadResult:
|
||||
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
|
||||
@@ -537,6 +689,17 @@ def datasource_catalogue(registry: object | None) -> DatasourceCatalogueProvider
|
||||
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
|
||||
@@ -554,6 +717,13 @@ def datasource_origins(registry: object | None) -> DatasourceOriginProvider | No
|
||||
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
|
||||
@@ -586,9 +756,15 @@ def _governance_mapping(value: object) -> Mapping[str, object]:
|
||||
|
||||
__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",
|
||||
@@ -604,6 +780,10 @@ __all__ = [
|
||||
"DatasourceOriginProvider",
|
||||
"DatasourceOriginReadRequest",
|
||||
"DatasourceOriginReadResult",
|
||||
"DatasourcePublicationProvider",
|
||||
"DatasourcePublicationRequest",
|
||||
"DatasourcePublicationResult",
|
||||
"DatasourcePublicationStatus",
|
||||
"DatasourceReadRequest",
|
||||
"DatasourceReadResult",
|
||||
"DatasourceShape",
|
||||
@@ -611,7 +791,14 @@ __all__ = [
|
||||
"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,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",
|
||||
]
|
||||
@@ -182,6 +182,8 @@ class PlatformEventOutbox(Protocol):
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
tenantless_only: bool = False,
|
||||
consumers: Sequence[DurableEventConsumer] = (),
|
||||
observer: EventHandler | None = None,
|
||||
limit: int = 100,
|
||||
@@ -203,6 +205,8 @@ class PlatformEventOutbox(Protocol):
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
tenantless_only: bool = False,
|
||||
before: datetime,
|
||||
limit: int = 500,
|
||||
) -> Mapping[str, int]:
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from govoplan_core.core.access import ResourceAccessExplanationProvider
|
||||
@@ -9,6 +10,49 @@ from govoplan_core.core.access import ResourceAccessExplanationProvider
|
||||
|
||||
CAPABILITY_FILES_ACCESS = "files.access"
|
||||
CAPABILITY_FILES_ARTIFACT_STORE = "files.artifact_store"
|
||||
CAPABILITY_FILES_POSTBOX_REFERENCES = "files.postbox_references"
|
||||
CAPABILITY_FILES_TABULAR_CONTENT = "files.tabular_content"
|
||||
|
||||
|
||||
class ManagedTabularFileError(ValueError):
|
||||
"""Stable base error for exact-version managed tabular file access."""
|
||||
|
||||
|
||||
class ManagedTabularFileNotFoundError(ManagedTabularFileError):
|
||||
pass
|
||||
|
||||
|
||||
class ManagedTabularFileAccessError(ManagedTabularFileError):
|
||||
pass
|
||||
|
||||
|
||||
class ManagedTabularFileUnavailableError(ManagedTabularFileError):
|
||||
pass
|
||||
|
||||
|
||||
class ManagedTabularFileValidationError(ManagedTabularFileError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ManagedTabularFile:
|
||||
"""Authorized metadata for one immutable managed file version."""
|
||||
|
||||
file_asset_id: str
|
||||
file_version_id: str
|
||||
filename: str
|
||||
display_path: str
|
||||
content_type: str | None
|
||||
size_bytes: int
|
||||
sha256: str
|
||||
updated_at: datetime | None = None
|
||||
current_version: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ManagedTabularFileContent:
|
||||
file: ManagedTabularFile
|
||||
payload: bytes
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -34,6 +78,30 @@ class ManagedArtifactRef:
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxFileReferenceRequest:
|
||||
reference_type: str
|
||||
reference_id: str
|
||||
postbox_id: str
|
||||
message_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxFileReferenceRef:
|
||||
reference_type: str
|
||||
reference_id: str
|
||||
available: bool
|
||||
reason_code: str
|
||||
file_asset_id: str | None = None
|
||||
file_version_id: str | None = None
|
||||
filename: str | None = None
|
||||
content_type: str | None = None
|
||||
size_bytes: int | None = None
|
||||
sha256: str | None = None
|
||||
download_path: str | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class FileAccessProvider(ResourceAccessExplanationProvider, Protocol):
|
||||
"""Resource-level access explanation provider for Files-owned resources."""
|
||||
@@ -50,3 +118,111 @@ class ManagedArtifactStore(Protocol):
|
||||
*,
|
||||
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,236 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal, Mapping, cast
|
||||
|
||||
|
||||
InformationGovernanceAdoption = Literal[
|
||||
"not_applicable",
|
||||
"contract_only",
|
||||
"partial",
|
||||
"enforced",
|
||||
]
|
||||
|
||||
INFORMATION_GOVERNANCE_ADOPTION_ORDER: tuple[InformationGovernanceAdoption, ...] = (
|
||||
"not_applicable",
|
||||
"contract_only",
|
||||
"partial",
|
||||
"enforced",
|
||||
)
|
||||
|
||||
|
||||
class InformationGovernanceDeclarationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InformationGovernanceDimension:
|
||||
"""Truthful module-level adoption claim for one cross-cutting dimension."""
|
||||
|
||||
adoption: InformationGovernanceAdoption = "contract_only"
|
||||
object_types: tuple[str, ...] = ()
|
||||
evidence: tuple[str, ...] = ()
|
||||
limitation: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.adoption not in INFORMATION_GOVERNANCE_ADOPTION_ORDER:
|
||||
raise InformationGovernanceDeclarationError(
|
||||
f"Unsupported information-governance adoption: {self.adoption!r}."
|
||||
)
|
||||
for field_name in ("object_types", "evidence"):
|
||||
values = getattr(self, field_name)
|
||||
if len(values) != len(set(values)) or any(not item.strip() for item in values):
|
||||
raise InformationGovernanceDeclarationError(
|
||||
f"Information-governance {field_name.replace('_', ' ')} must "
|
||||
"contain unique non-empty values."
|
||||
)
|
||||
if self.adoption == "enforced" and not self.evidence:
|
||||
raise InformationGovernanceDeclarationError(
|
||||
"An enforced information-governance dimension requires evidence."
|
||||
)
|
||||
if self.adoption in {"partial", "enforced"} and not self.object_types:
|
||||
raise InformationGovernanceDeclarationError(
|
||||
"Partial and enforced information-governance dimensions must "
|
||||
"name their covered object types."
|
||||
)
|
||||
if self.adoption == "not_applicable" and self.object_types:
|
||||
raise InformationGovernanceDeclarationError(
|
||||
"A non-applicable information-governance dimension cannot declare object types."
|
||||
)
|
||||
if self.adoption in {"contract_only", "partial"} and not str(
|
||||
self.limitation or ""
|
||||
).strip():
|
||||
raise InformationGovernanceDeclarationError(
|
||||
"Contract-only and partial adoption must state the current limitation."
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"adoption": self.adoption,
|
||||
"object_types": list(self.object_types),
|
||||
"evidence": list(self.evidence),
|
||||
"limitation": self.limitation,
|
||||
}
|
||||
|
||||
|
||||
def _contract_only_dimension() -> InformationGovernanceDimension:
|
||||
return InformationGovernanceDimension(
|
||||
adoption="contract_only",
|
||||
limitation=(
|
||||
"The platform contract applies, but module-specific adoption evidence "
|
||||
"has not been declared."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModuleInformationGovernance:
|
||||
"""Cross-cutting data-use requirements and honest adoption evidence."""
|
||||
|
||||
temporal_browsing: InformationGovernanceDimension = field(
|
||||
default_factory=_contract_only_dimension
|
||||
)
|
||||
purpose_aware_access: InformationGovernanceDimension = field(
|
||||
default_factory=_contract_only_dimension
|
||||
)
|
||||
retention: InformationGovernanceDimension = field(
|
||||
default_factory=_contract_only_dimension
|
||||
)
|
||||
institutional_context: InformationGovernanceDimension = field(
|
||||
default_factory=_contract_only_dimension
|
||||
)
|
||||
current_authorization_for_historical_reads: bool = True
|
||||
contract_version: str = "1"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.contract_version != "1":
|
||||
raise InformationGovernanceDeclarationError(
|
||||
"Unsupported module information-governance contract version."
|
||||
)
|
||||
if not self.current_authorization_for_historical_reads:
|
||||
raise InformationGovernanceDeclarationError(
|
||||
"Historical reads must always use current authorization."
|
||||
)
|
||||
for name, dimension in self.dimensions.items():
|
||||
if not isinstance(dimension, InformationGovernanceDimension):
|
||||
raise InformationGovernanceDeclarationError(
|
||||
f"Information-governance dimension {name!r} has an invalid value."
|
||||
)
|
||||
|
||||
@property
|
||||
def dimensions(self) -> Mapping[str, InformationGovernanceDimension]:
|
||||
return {
|
||||
"temporal_browsing": self.temporal_browsing,
|
||||
"purpose_aware_access": self.purpose_aware_access,
|
||||
"retention": self.retention,
|
||||
"institutional_context": self.institutional_context,
|
||||
}
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"contract_version": self.contract_version,
|
||||
"current_authorization_for_historical_reads": (
|
||||
self.current_authorization_for_historical_reads
|
||||
),
|
||||
"dimensions": {
|
||||
name: dimension.to_dict()
|
||||
for name, dimension in self.dimensions.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def information_governance_from_mapping(
|
||||
value: Mapping[str, object],
|
||||
) -> ModuleInformationGovernance:
|
||||
raw_dimensions = value.get("dimensions")
|
||||
if not isinstance(raw_dimensions, Mapping):
|
||||
raise InformationGovernanceDeclarationError(
|
||||
"Information-governance dimensions must be an object."
|
||||
)
|
||||
|
||||
def dimension(name: str) -> InformationGovernanceDimension:
|
||||
raw_dimension = raw_dimensions.get(name)
|
||||
if not isinstance(raw_dimension, Mapping):
|
||||
raise InformationGovernanceDeclarationError(
|
||||
f"Information-governance dimension {name!r} must be an object."
|
||||
)
|
||||
|
||||
def text_tuple(field_name: str) -> tuple[str, ...]:
|
||||
raw_values = raw_dimension.get(field_name, ())
|
||||
if not isinstance(raw_values, (list, tuple)):
|
||||
raise InformationGovernanceDeclarationError(
|
||||
f"Information-governance {name}.{field_name} must be a list."
|
||||
)
|
||||
if any(not isinstance(item, str) for item in raw_values):
|
||||
raise InformationGovernanceDeclarationError(
|
||||
f"Information-governance {name}.{field_name} must contain strings."
|
||||
)
|
||||
return tuple(raw_values)
|
||||
|
||||
raw_limitation = raw_dimension.get("limitation")
|
||||
raw_adoption = raw_dimension.get("adoption") or "contract_only"
|
||||
if not isinstance(raw_adoption, str):
|
||||
raise InformationGovernanceDeclarationError(
|
||||
f"Information-governance {name}.adoption must be a string."
|
||||
)
|
||||
if raw_limitation is not None and not isinstance(raw_limitation, str):
|
||||
raise InformationGovernanceDeclarationError(
|
||||
f"Information-governance {name}.limitation must be a string."
|
||||
)
|
||||
return InformationGovernanceDimension(
|
||||
adoption=cast(
|
||||
InformationGovernanceAdoption,
|
||||
raw_adoption,
|
||||
),
|
||||
object_types=text_tuple("object_types"),
|
||||
evidence=text_tuple("evidence"),
|
||||
limitation=raw_limitation,
|
||||
)
|
||||
|
||||
current_authorization = value.get(
|
||||
"current_authorization_for_historical_reads",
|
||||
True,
|
||||
)
|
||||
if not isinstance(current_authorization, bool):
|
||||
raise InformationGovernanceDeclarationError(
|
||||
"current_authorization_for_historical_reads must be boolean."
|
||||
)
|
||||
return ModuleInformationGovernance(
|
||||
contract_version=str(value.get("contract_version") or "1"),
|
||||
current_authorization_for_historical_reads=current_authorization,
|
||||
temporal_browsing=dimension("temporal_browsing"),
|
||||
purpose_aware_access=dimension("purpose_aware_access"),
|
||||
retention=dimension("retention"),
|
||||
institutional_context=dimension("institutional_context"),
|
||||
)
|
||||
|
||||
|
||||
def information_governance_maturity_issues(
|
||||
declaration: ModuleInformationGovernance,
|
||||
*,
|
||||
maturity: str | None,
|
||||
) -> tuple[str, ...]:
|
||||
if maturity not in {"reference_ready", "supported", "lts"}:
|
||||
return ()
|
||||
incomplete = [
|
||||
name
|
||||
for name, dimension in declaration.dimensions.items()
|
||||
if dimension.adoption not in {"not_applicable", "enforced"}
|
||||
]
|
||||
if not incomplete:
|
||||
return ()
|
||||
return (
|
||||
f"Maturity {maturity!r} requires enforced or explicitly non-applicable "
|
||||
"information governance for: " + ", ".join(incomplete),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"INFORMATION_GOVERNANCE_ADOPTION_ORDER",
|
||||
"InformationGovernanceAdoption",
|
||||
"InformationGovernanceDeclarationError",
|
||||
"InformationGovernanceDimension",
|
||||
"ModuleInformationGovernance",
|
||||
"information_governance_from_mapping",
|
||||
"information_governance_maturity_issues",
|
||||
]
|
||||
@@ -0,0 +1,614 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
|
||||
DEPLOYMENT_CAPABILITIES_ENV = "GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH"
|
||||
INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX = (
|
||||
"infrastructure.dependency_inventory."
|
||||
)
|
||||
MAX_CAPABILITY_DOCUMENT_BYTES = 256 * 1024
|
||||
CAPABILITY_STATES = frozenset(
|
||||
{
|
||||
"configured",
|
||||
"available_unconfigured",
|
||||
"externally_supplied",
|
||||
"unavailable",
|
||||
}
|
||||
)
|
||||
_ENV_REFERENCE_RE = re.compile(r"^env:[A-Za-z_][A-Za-z0-9_]*$")
|
||||
_DEPENDENCY_STATES = frozenset(
|
||||
{"active", "inactive", "data_present", "pending_work", "runtime_binding"}
|
||||
)
|
||||
|
||||
|
||||
class InfrastructureCapabilityReceiptError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InfrastructureDependency:
|
||||
"""A non-secret module-owned dependency on deployment infrastructure."""
|
||||
|
||||
capability_id: str
|
||||
module_id: str
|
||||
dependency_type: str
|
||||
dependency_ref: str
|
||||
state: str
|
||||
scope: str
|
||||
summary: str
|
||||
metrics: Mapping[str, int]
|
||||
required_action: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for field_name, value, maximum in (
|
||||
("capability_id", self.capability_id, 120),
|
||||
("module_id", self.module_id, 120),
|
||||
("dependency_type", self.dependency_type, 120),
|
||||
("dependency_ref", self.dependency_ref, 240),
|
||||
("scope", self.scope, 120),
|
||||
("summary", self.summary, 1000),
|
||||
("required_action", self.required_action, 1000),
|
||||
):
|
||||
if (
|
||||
not value.strip()
|
||||
or len(value) > maximum
|
||||
or any(ord(char) < 32 for char in value)
|
||||
):
|
||||
raise ValueError(
|
||||
f"Infrastructure dependency {field_name} is invalid."
|
||||
)
|
||||
if self.state not in _DEPENDENCY_STATES:
|
||||
raise ValueError(
|
||||
f"Infrastructure dependency state is unsupported: {self.state!r}."
|
||||
)
|
||||
if len(self.metrics) > 20 or any(
|
||||
not isinstance(key, str)
|
||||
or not key.strip()
|
||||
or len(key) > 80
|
||||
or any(ord(char) < 32 for char in key)
|
||||
or type(value) is not int
|
||||
or value < 0
|
||||
for key, value in self.metrics.items()
|
||||
):
|
||||
raise ValueError("Infrastructure dependency metrics are invalid.")
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"capability_id": self.capability_id,
|
||||
"module_id": self.module_id,
|
||||
"dependency_type": self.dependency_type,
|
||||
"dependency_ref": self.dependency_ref,
|
||||
"state": self.state,
|
||||
"scope": self.scope,
|
||||
"summary": self.summary,
|
||||
"metrics": dict(sorted(self.metrics.items())),
|
||||
"required_action": self.required_action,
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class InfrastructureDependencyProvider(Protocol):
|
||||
module_id: str
|
||||
capability_ids: tuple[str, ...]
|
||||
|
||||
def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]:
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InfrastructureDependencyProviderReport:
|
||||
module_id: str
|
||||
capability_ids: tuple[str, ...]
|
||||
state: str
|
||||
dependency_count: int
|
||||
error: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"module_id": self.module_id,
|
||||
"capability_ids": list(self.capability_ids),
|
||||
"state": self.state,
|
||||
"dependency_count": self.dependency_count,
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InfrastructureDependencyInventory:
|
||||
installation_id: str
|
||||
generated_at: str
|
||||
complete: bool
|
||||
inspected_capability_ids: tuple[str, ...]
|
||||
providers: tuple[InfrastructureDependencyProviderReport, ...]
|
||||
dependencies: tuple[InfrastructureDependency, ...]
|
||||
schema_version: int = 1
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"installation_id": self.installation_id,
|
||||
"generated_at": self.generated_at,
|
||||
"complete": self.complete,
|
||||
"inspected_capability_ids": list(self.inspected_capability_ids),
|
||||
"providers": [item.to_dict() for item in self.providers],
|
||||
"dependencies": [item.to_dict() for item in self.dependencies],
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InfrastructureCapability:
|
||||
id: str
|
||||
label: str
|
||||
state: str
|
||||
source: str
|
||||
detail: str
|
||||
endpoint: Mapping[str, object]
|
||||
secret_refs: tuple[str, ...]
|
||||
dependent_modules: tuple[str, ...]
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"label": self.label,
|
||||
"state": self.state,
|
||||
"source": self.source,
|
||||
"detail": self.detail,
|
||||
"endpoint": dict(self.endpoint),
|
||||
"secret_refs": list(self.secret_refs),
|
||||
"dependent_modules": list(self.dependent_modules),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InfrastructurePostInstallTask:
|
||||
id: str
|
||||
resume_key: str
|
||||
capability_id: str
|
||||
state: str
|
||||
owner_module: str
|
||||
summary: str
|
||||
required_inputs: tuple[str, ...]
|
||||
secret_boundary: str
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"resume_key": self.resume_key,
|
||||
"capability_id": self.capability_id,
|
||||
"state": self.state,
|
||||
"owner_module": self.owner_module,
|
||||
"summary": self.summary,
|
||||
"required_inputs": list(self.required_inputs),
|
||||
"secret_boundary": self.secret_boundary,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InfrastructureCapabilityReceipt:
|
||||
installation_id: str
|
||||
profile: str
|
||||
capabilities: tuple[InfrastructureCapability, ...]
|
||||
post_install_tasks: tuple[InfrastructurePostInstallTask, ...]
|
||||
schema_version: int = 1
|
||||
|
||||
def capability(self, capability_id: str) -> InfrastructureCapability | None:
|
||||
return next(
|
||||
(item for item in self.capabilities if item.id == capability_id),
|
||||
None,
|
||||
)
|
||||
|
||||
def tasks_for(
|
||||
self,
|
||||
*,
|
||||
capability_id: str | None = None,
|
||||
owner_module: str | None = None,
|
||||
) -> tuple[InfrastructurePostInstallTask, ...]:
|
||||
return tuple(
|
||||
item
|
||||
for item in self.post_install_tasks
|
||||
if (capability_id is None or item.capability_id == capability_id)
|
||||
and (owner_module is None or item.owner_module == owner_module)
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"installation_id": self.installation_id,
|
||||
"profile": self.profile,
|
||||
"capabilities": [item.to_dict() for item in self.capabilities],
|
||||
"post_install_tasks": [
|
||||
item.to_dict() for item in self.post_install_tasks
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def load_infrastructure_capability_receipt(
|
||||
path: Path | str | None = None,
|
||||
) -> InfrastructureCapabilityReceipt | None:
|
||||
configured_path = path
|
||||
if configured_path is None:
|
||||
raw_path = os.getenv(DEPLOYMENT_CAPABILITIES_ENV, "").strip()
|
||||
if not raw_path:
|
||||
return None
|
||||
configured_path = raw_path
|
||||
return read_infrastructure_capability_receipt(Path(configured_path))
|
||||
|
||||
|
||||
def read_infrastructure_capability_receipt(
|
||||
path: Path,
|
||||
) -> InfrastructureCapabilityReceipt:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise InfrastructureCapabilityReceiptError(
|
||||
"Deployment capability receipt is not a regular file."
|
||||
)
|
||||
try:
|
||||
expected_size = path.stat().st_size
|
||||
if expected_size > MAX_CAPABILITY_DOCUMENT_BYTES:
|
||||
raise InfrastructureCapabilityReceiptError(
|
||||
"Deployment capability receipt exceeds 256 KiB."
|
||||
)
|
||||
raw = path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise InfrastructureCapabilityReceiptError(
|
||||
"Deployment capability receipt could not be read."
|
||||
) from exc
|
||||
if len(raw) != expected_size:
|
||||
raise InfrastructureCapabilityReceiptError(
|
||||
"Deployment capability receipt changed while being read."
|
||||
)
|
||||
try:
|
||||
payload = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise InfrastructureCapabilityReceiptError(
|
||||
"Deployment capability receipt is not valid UTF-8 JSON."
|
||||
) from exc
|
||||
return infrastructure_capability_receipt_from_mapping(payload)
|
||||
|
||||
|
||||
def infrastructure_capability_receipt_from_mapping(
|
||||
payload: object,
|
||||
) -> InfrastructureCapabilityReceipt:
|
||||
if (
|
||||
not isinstance(payload, Mapping)
|
||||
or type(payload.get("schema_version")) is not int
|
||||
or payload.get("schema_version") != 1
|
||||
):
|
||||
raise InfrastructureCapabilityReceiptError(
|
||||
"Deployment capability receipt has an unsupported schema."
|
||||
)
|
||||
raw_capabilities = payload.get("capabilities")
|
||||
if not isinstance(raw_capabilities, list) or len(raw_capabilities) > 100:
|
||||
raise InfrastructureCapabilityReceiptError(
|
||||
"Deployment capability receipt has invalid capabilities."
|
||||
)
|
||||
capabilities = tuple(_capability(item) for item in raw_capabilities)
|
||||
capability_ids = [item.id for item in capabilities]
|
||||
if len(capability_ids) != len(set(capability_ids)):
|
||||
raise InfrastructureCapabilityReceiptError(
|
||||
"Deployment capability receipt repeats a capability id."
|
||||
)
|
||||
raw_tasks = payload.get("post_install_tasks", [])
|
||||
if not isinstance(raw_tasks, list) or len(raw_tasks) > 100:
|
||||
raise InfrastructureCapabilityReceiptError(
|
||||
"Deployment capability receipt has invalid post-install tasks."
|
||||
)
|
||||
tasks = tuple(_task(item) for item in raw_tasks)
|
||||
known_capability_ids = set(capability_ids)
|
||||
if any(item.capability_id not in known_capability_ids for item in tasks):
|
||||
raise InfrastructureCapabilityReceiptError(
|
||||
"Deployment post-install task references an unknown capability."
|
||||
)
|
||||
return InfrastructureCapabilityReceipt(
|
||||
installation_id=_required_text(payload, "installation_id", maximum=100),
|
||||
profile=_required_text(payload, "profile", maximum=100),
|
||||
capabilities=capabilities,
|
||||
post_install_tasks=tasks,
|
||||
)
|
||||
|
||||
|
||||
def deployment_capability_status(
|
||||
path: Path | str | None = None,
|
||||
) -> dict[str, object]:
|
||||
try:
|
||||
receipt = load_infrastructure_capability_receipt(path)
|
||||
except InfrastructureCapabilityReceiptError as exc:
|
||||
return _unavailable_status(configured=True, error=str(exc))
|
||||
if receipt is None:
|
||||
return _unavailable_status(configured=False, error=None)
|
||||
return {
|
||||
"configured": True,
|
||||
"available": True,
|
||||
**receipt.to_dict(),
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
def collect_infrastructure_dependency_inventory(
|
||||
registry: object,
|
||||
*,
|
||||
installation_id: str,
|
||||
observed_at: datetime | None = None,
|
||||
) -> InfrastructureDependencyInventory:
|
||||
"""Collect actual module-owned dependencies without importing module internals."""
|
||||
|
||||
normalized_installation_id = installation_id.strip()
|
||||
if not normalized_installation_id or len(normalized_installation_id) > 100:
|
||||
raise ValueError("Infrastructure dependency installation id is invalid.")
|
||||
capability_names = getattr(registry, "capability_names", None)
|
||||
capability = getattr(registry, "capability", None)
|
||||
if not callable(capability_names) or not callable(capability):
|
||||
raise ValueError("Infrastructure dependency inventory requires a module registry.")
|
||||
|
||||
provider_names = tuple(
|
||||
name
|
||||
for name in capability_names()
|
||||
if isinstance(name, str)
|
||||
and name.startswith(INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX)
|
||||
)
|
||||
reports: list[InfrastructureDependencyProviderReport] = []
|
||||
dependencies: list[InfrastructureDependency] = []
|
||||
inspected_capability_ids: set[str] = set()
|
||||
complete = True
|
||||
|
||||
for provider_name in sorted(provider_names):
|
||||
expected_module_id = provider_name.removeprefix(
|
||||
INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX
|
||||
)
|
||||
module_id = expected_module_id or "unknown"
|
||||
declared_ids: tuple[str, ...] = ()
|
||||
try:
|
||||
provider = capability(provider_name)
|
||||
if not isinstance(provider, InfrastructureDependencyProvider):
|
||||
raise TypeError("provider does not implement the inventory contract")
|
||||
module_id = provider.module_id.strip()
|
||||
declared_ids = tuple(
|
||||
sorted(
|
||||
{
|
||||
item.strip()
|
||||
for item in provider.capability_ids
|
||||
if isinstance(item, str) and item.strip()
|
||||
}
|
||||
)
|
||||
)
|
||||
if (
|
||||
module_id != expected_module_id
|
||||
or len(module_id) > 120
|
||||
or any(ord(char) < 32 for char in module_id)
|
||||
or not declared_ids
|
||||
or len(declared_ids) > 30
|
||||
or any(
|
||||
len(item) > 120 or any(ord(char) < 32 for char in item)
|
||||
for item in declared_ids
|
||||
)
|
||||
):
|
||||
raise ValueError("provider identity or capability declaration is invalid")
|
||||
provider_dependencies = tuple(provider.infrastructure_dependencies())
|
||||
if len(provider_dependencies) > 10_000:
|
||||
raise ValueError("provider dependency inventory is too large")
|
||||
seen_refs: set[tuple[str, str, str]] = set()
|
||||
for item in provider_dependencies:
|
||||
if not isinstance(item, InfrastructureDependency):
|
||||
raise TypeError("provider returned an invalid dependency")
|
||||
if item.module_id != module_id or item.capability_id not in declared_ids:
|
||||
raise ValueError("provider returned a dependency outside its declaration")
|
||||
identity = (
|
||||
item.capability_id,
|
||||
item.dependency_type,
|
||||
item.dependency_ref,
|
||||
)
|
||||
if identity in seen_refs:
|
||||
raise ValueError("provider returned a duplicate dependency")
|
||||
seen_refs.add(identity)
|
||||
if len(dependencies) + len(provider_dependencies) > 10_000:
|
||||
raise ValueError("combined dependency inventory is too large")
|
||||
dependencies.extend(provider_dependencies)
|
||||
inspected_capability_ids.update(declared_ids)
|
||||
reports.append(
|
||||
InfrastructureDependencyProviderReport(
|
||||
module_id=module_id,
|
||||
capability_ids=declared_ids,
|
||||
state="complete",
|
||||
dependency_count=len(provider_dependencies),
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
complete = False
|
||||
inspected_capability_ids.update(declared_ids)
|
||||
reports.append(
|
||||
InfrastructureDependencyProviderReport(
|
||||
module_id=module_id,
|
||||
capability_ids=declared_ids,
|
||||
state="error",
|
||||
dependency_count=0,
|
||||
error=(
|
||||
f"{type(exc).__name__}: provider inventory could not be completed"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
timestamp = observed_at or datetime.now(UTC)
|
||||
if timestamp.tzinfo is None:
|
||||
timestamp = timestamp.replace(tzinfo=UTC)
|
||||
return InfrastructureDependencyInventory(
|
||||
installation_id=normalized_installation_id,
|
||||
generated_at=timestamp.astimezone(UTC).isoformat(),
|
||||
complete=complete,
|
||||
inspected_capability_ids=tuple(sorted(inspected_capability_ids)),
|
||||
providers=tuple(
|
||||
sorted(reports, key=lambda item: (item.module_id, item.capability_ids))
|
||||
),
|
||||
dependencies=tuple(
|
||||
sorted(
|
||||
dependencies,
|
||||
key=lambda item: (
|
||||
item.capability_id,
|
||||
item.module_id,
|
||||
item.dependency_type,
|
||||
item.dependency_ref,
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _capability(value: object) -> InfrastructureCapability:
|
||||
if not isinstance(value, Mapping):
|
||||
raise InfrastructureCapabilityReceiptError(
|
||||
"Deployment capability entries must be objects."
|
||||
)
|
||||
state = _required_text(value, "state", maximum=40)
|
||||
if state not in CAPABILITY_STATES:
|
||||
raise InfrastructureCapabilityReceiptError(
|
||||
f"Deployment capability state is unsupported: {state!r}."
|
||||
)
|
||||
normalized_endpoint = _normalized_endpoint(value.get("endpoint", {}))
|
||||
secret_refs = _string_list(value.get("secret_refs"), maximum_items=30)
|
||||
if any(not _ENV_REFERENCE_RE.fullmatch(item) for item in secret_refs):
|
||||
raise InfrastructureCapabilityReceiptError(
|
||||
"Deployment capability secrets must use environment references."
|
||||
)
|
||||
return InfrastructureCapability(
|
||||
id=_required_text(value, "id", maximum=120),
|
||||
label=_required_text(value, "label", maximum=200),
|
||||
state=state,
|
||||
source=_required_text(value, "source", maximum=120),
|
||||
detail=_required_text(value, "detail", maximum=1000),
|
||||
endpoint=normalized_endpoint,
|
||||
secret_refs=secret_refs,
|
||||
dependent_modules=_string_list(
|
||||
value.get("dependent_modules"),
|
||||
maximum_items=100,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _normalized_endpoint(value: object) -> dict[str, object]:
|
||||
if not isinstance(value, Mapping) or len(value) > 10:
|
||||
raise InfrastructureCapabilityReceiptError(
|
||||
"Deployment capability endpoint metadata is invalid."
|
||||
)
|
||||
endpoint: dict[str, object] = {}
|
||||
for key, raw in value.items():
|
||||
if not isinstance(key, str) or not key or len(key) > 50:
|
||||
raise InfrastructureCapabilityReceiptError(
|
||||
"Deployment capability endpoint key is invalid."
|
||||
)
|
||||
if any(
|
||||
marker in key.casefold()
|
||||
for marker in ("password", "secret", "token", "credential")
|
||||
):
|
||||
raise InfrastructureCapabilityReceiptError(
|
||||
"Deployment capability endpoint metadata contains a secret field."
|
||||
)
|
||||
if key.casefold() == "port" and (
|
||||
type(raw) is not int or not 1 <= raw <= 65535
|
||||
):
|
||||
raise InfrastructureCapabilityReceiptError(
|
||||
"Deployment capability endpoint port is invalid."
|
||||
)
|
||||
if isinstance(raw, bool) or raw is None:
|
||||
endpoint[key] = raw
|
||||
elif isinstance(raw, int):
|
||||
endpoint[key] = raw
|
||||
elif isinstance(raw, str) and len(raw) <= 500:
|
||||
endpoint[key] = raw
|
||||
else:
|
||||
raise InfrastructureCapabilityReceiptError(
|
||||
"Deployment capability endpoint value is invalid."
|
||||
)
|
||||
return endpoint
|
||||
|
||||
|
||||
def _task(value: object) -> InfrastructurePostInstallTask:
|
||||
if not isinstance(value, Mapping):
|
||||
raise InfrastructureCapabilityReceiptError(
|
||||
"Deployment post-install task entries must be objects."
|
||||
)
|
||||
return InfrastructurePostInstallTask(
|
||||
id=_required_text(value, "id", maximum=120),
|
||||
resume_key=_required_text(value, "resume_key", maximum=240),
|
||||
capability_id=_required_text(value, "capability_id", maximum=120),
|
||||
state=_required_text(value, "state", maximum=40),
|
||||
owner_module=_required_text(value, "owner_module", maximum=120),
|
||||
summary=_required_text(value, "summary", maximum=1000),
|
||||
required_inputs=_string_list(
|
||||
value.get("required_inputs"),
|
||||
maximum_items=30,
|
||||
),
|
||||
secret_boundary=_required_text(
|
||||
value,
|
||||
"secret_boundary",
|
||||
maximum=120,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _required_text(
|
||||
value: Mapping[str, Any],
|
||||
key: str,
|
||||
*,
|
||||
maximum: int,
|
||||
) -> str:
|
||||
raw = value.get(key)
|
||||
text = str(raw).strip() if raw is not None else ""
|
||||
if not text or len(text) > maximum:
|
||||
raise InfrastructureCapabilityReceiptError(
|
||||
f"Deployment capability field {key!r} is invalid."
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def _string_list(value: object, *, maximum_items: int) -> tuple[str, ...]:
|
||||
if not isinstance(value, list) or len(value) > maximum_items:
|
||||
raise InfrastructureCapabilityReceiptError(
|
||||
"Deployment capability list field is invalid."
|
||||
)
|
||||
result: list[str] = []
|
||||
for item in value:
|
||||
if not isinstance(item, str) or not item.strip() or len(item) > 500:
|
||||
raise InfrastructureCapabilityReceiptError(
|
||||
"Deployment capability list item is invalid."
|
||||
)
|
||||
result.append(item.strip())
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _unavailable_status(*, configured: bool, error: str | None) -> dict[str, object]:
|
||||
return {
|
||||
"configured": configured,
|
||||
"available": False,
|
||||
"schema_version": None,
|
||||
"installation_id": None,
|
||||
"profile": None,
|
||||
"capabilities": [],
|
||||
"post_install_tasks": [],
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_STATES",
|
||||
"DEPLOYMENT_CAPABILITIES_ENV",
|
||||
"INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX",
|
||||
"InfrastructureCapability",
|
||||
"InfrastructureCapabilityReceipt",
|
||||
"InfrastructureCapabilityReceiptError",
|
||||
"InfrastructureDependency",
|
||||
"InfrastructureDependencyInventory",
|
||||
"InfrastructureDependencyProvider",
|
||||
"InfrastructureDependencyProviderReport",
|
||||
"InfrastructurePostInstallTask",
|
||||
"collect_infrastructure_dependency_inventory",
|
||||
"deployment_capability_status",
|
||||
"infrastructure_capability_receipt_from_mapping",
|
||||
"load_infrastructure_capability_receipt",
|
||||
"read_infrastructure_capability_receipt",
|
||||
]
|
||||
@@ -698,12 +698,15 @@ def _validate_module_catalog_trust(
|
||||
"A module catalog source is configured without a trusted keyring file.",
|
||||
"Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.",
|
||||
)
|
||||
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL")):
|
||||
if not (
|
||||
_clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS"))
|
||||
or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL"))
|
||||
):
|
||||
collector.add(
|
||||
"error",
|
||||
"GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL",
|
||||
"GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS",
|
||||
"A module catalog source is configured without an approved release channel.",
|
||||
"Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.",
|
||||
"Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS=stable or another approved deployment channel.",
|
||||
)
|
||||
|
||||
|
||||
@@ -734,6 +737,8 @@ CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
|
||||
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
|
||||
SCHEDULING_PUBLIC_SELF_ENROLLMENT_ENABLED=true
|
||||
SCHEDULING_PUBLIC_SELF_ENROLLMENT_MAX_CAPACITY=10000
|
||||
|
||||
# Deployment-wide connector egress policy. Enable private networks only when
|
||||
# this installation intentionally integrates with internal services.
|
||||
@@ -777,7 +782,7 @@ DEV_MAILBOX_API_ENABLED=false
|
||||
|
||||
GOVOPLAN_MODULE_PACKAGE_CATALOG_URL=https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json
|
||||
GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE=/etc/govoplan/catalog-keyring.json
|
||||
GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable
|
||||
GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS=stable
|
||||
"""
|
||||
|
||||
|
||||
@@ -815,6 +820,8 @@ CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
|
||||
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
|
||||
SCHEDULING_PUBLIC_SELF_ENROLLMENT_ENABLED=true
|
||||
SCHEDULING_PUBLIC_SELF_ENROLLMENT_MAX_CAPACITY=10000
|
||||
|
||||
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
|
||||
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import AsyncIterator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from threading import RLock
|
||||
|
||||
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, status
|
||||
from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException, Request, status
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.core.module_management import ModuleManagementError, REQUIRED_PLATFORM_MODULES, plan_desired_enabled_modules
|
||||
from govoplan_core.core.module_entitlements import (
|
||||
ModuleEntitlementResolutionError,
|
||||
TenantModuleUnavailable,
|
||||
tenant_execution_scope,
|
||||
)
|
||||
from govoplan_core.core.module_lifecycle_recovery import (
|
||||
ModuleLifecycleRecovery,
|
||||
begin_runtime_graph_recovery,
|
||||
canonical_sha256,
|
||||
)
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.runtime import configure_runtime
|
||||
from govoplan_core.core.workflows import (
|
||||
workflow_definition_contribution_provider,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.server.route_validation import validate_router_can_mount
|
||||
|
||||
|
||||
@@ -26,12 +40,75 @@ class ModuleLifecycleResult:
|
||||
|
||||
|
||||
def require_module_active(module_id: str):
|
||||
def dependency(request: Request) -> None:
|
||||
async def dependency(
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
authorization: str | None = Header(default=None),
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
) -> AsyncIterator[None]:
|
||||
registry = getattr(request.app.state, "govoplan_registry", None)
|
||||
if isinstance(registry, PlatformRegistry) and registry.has_module(module_id):
|
||||
return
|
||||
if not isinstance(registry, PlatformRegistry) or not registry.has_module(module_id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Module is disabled: {module_id}")
|
||||
|
||||
tenant_id: str | None = None
|
||||
if not authorization and not x_api_key and not request.cookies:
|
||||
public_resolver = registry.public_tenant_resolver(module_id)
|
||||
if public_resolver is not None:
|
||||
tenant_id = public_resolver(request, session)
|
||||
if tenant_id is None:
|
||||
yield
|
||||
return
|
||||
else:
|
||||
try:
|
||||
principal = get_api_principal(
|
||||
request,
|
||||
session,
|
||||
authorization=authorization,
|
||||
x_api_key=x_api_key,
|
||||
)
|
||||
except HTTPException as exc:
|
||||
if exc.status_code in {
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
}:
|
||||
yield
|
||||
return
|
||||
raise
|
||||
if (
|
||||
not isinstance(principal, ApiPrincipal)
|
||||
or principal.principal.tenant_id is None
|
||||
):
|
||||
yield
|
||||
return
|
||||
tenant_id = principal.principal.tenant_id
|
||||
|
||||
resolver = registry.tenant_entitlement_resolver()
|
||||
try:
|
||||
admission = resolver.require(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
module_id=module_id,
|
||||
work_state="interactive",
|
||||
)
|
||||
except TenantModuleUnavailable as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Module is unavailable in the active tenant: {module_id}",
|
||||
) from exc
|
||||
except (ModuleEntitlementResolutionError, RuntimeError, SQLAlchemyError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Tenant module entitlement could not be resolved.",
|
||||
) from exc
|
||||
request.state.govoplan_module_admission = admission
|
||||
with tenant_execution_scope(
|
||||
resolver,
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
work_state="interactive",
|
||||
):
|
||||
yield
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
@@ -99,13 +176,40 @@ class ModuleLifecycleManager:
|
||||
next_set = set(plan.enabled_modules)
|
||||
activated = tuple(module_id for module_id in plan.enabled_modules if module_id not in previous_set)
|
||||
deactivated = tuple(module_id for module_id in previous if module_id not in next_set)
|
||||
graph_changes = bool(activated or deactivated)
|
||||
recovery: ModuleLifecycleRecovery | None = None
|
||||
if graph_changes or migrate:
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
with get_database().session() as recovery_session:
|
||||
recovery = begin_runtime_graph_recovery(
|
||||
recovery_session,
|
||||
previous_modules=previous,
|
||||
requested_modules=plan.enabled_modules,
|
||||
migrate=migrate,
|
||||
)
|
||||
|
||||
old_manifests = {
|
||||
manifest.id: manifest for manifest in self.registry.manifests()
|
||||
}
|
||||
try:
|
||||
if recovery is not None:
|
||||
recovery.checkpoint(
|
||||
kind="runtime-graph-effect-started",
|
||||
summary="Runtime module graph entered its mutation boundary",
|
||||
evidence={
|
||||
"activated_sha256": canonical_sha256(activated),
|
||||
"deactivated_sha256": canonical_sha256(deactivated),
|
||||
"migrate": migrate,
|
||||
},
|
||||
effect_started=True,
|
||||
)
|
||||
|
||||
if migrate:
|
||||
self._migrate(plan.enabled_modules)
|
||||
|
||||
mounted = tuple(module_id for module_id in plan.enabled_modules if self._mount_module_router(module_id))
|
||||
|
||||
old_manifests = {manifest.id: manifest for manifest in self.registry.manifests()}
|
||||
for module_id in deactivated:
|
||||
hook = old_manifests[module_id].on_deactivate
|
||||
if hook is not None:
|
||||
@@ -119,11 +223,64 @@ class ModuleLifecycleManager:
|
||||
if hook is not None:
|
||||
hook(self.context)
|
||||
|
||||
self.reconcile_workflow_definitions()
|
||||
reconciliation = self.reconcile_workflow_definitions()
|
||||
|
||||
if self._app is not None:
|
||||
self._app.openapi_schema = None
|
||||
|
||||
if recovery is not None:
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
with get_database().session() as recovery_session:
|
||||
recovery.succeed(
|
||||
recovery_session,
|
||||
evidence={
|
||||
"active_graph_sha256": canonical_sha256(
|
||||
self.active_module_ids()
|
||||
),
|
||||
"mounted_graph_sha256": canonical_sha256(
|
||||
self.mounted_module_ids()
|
||||
),
|
||||
"workflow_reconciliation_sha256": canonical_sha256(
|
||||
reconciliation
|
||||
),
|
||||
},
|
||||
commit_projection=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
self.registry.replace(old_manifests.values())
|
||||
self.configure_runtime()
|
||||
if self._app is not None:
|
||||
self._app.openapi_schema = None
|
||||
if recovery is not None:
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
recovery.unresolved(
|
||||
summary="Runtime graph mutation did not reach verified completion",
|
||||
evidence={
|
||||
"error_type": type(exc).__name__,
|
||||
"previous_graph_sha256": canonical_sha256(previous),
|
||||
"registry_restored": True,
|
||||
"migrate": migrate,
|
||||
},
|
||||
outcome_unknown=migrate,
|
||||
)
|
||||
if not migrate:
|
||||
with get_database().session() as recovery_session:
|
||||
recovery.recovered(
|
||||
recovery_session,
|
||||
evidence={
|
||||
"active_graph_sha256": canonical_sha256(
|
||||
self.active_module_ids()
|
||||
),
|
||||
"previous_graph_restored": (
|
||||
self.active_module_ids() == previous
|
||||
),
|
||||
},
|
||||
summary="Previous runtime module graph was restored",
|
||||
)
|
||||
raise
|
||||
|
||||
return ModuleLifecycleResult(
|
||||
enabled_modules=plan.enabled_modules,
|
||||
activated_modules=activated,
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Protocol, runtime_checkable
|
||||
CAPABILITY_MAIL_DELIVERY_OUTBOX = "mail.delivery_outbox"
|
||||
CAPABILITY_MAIL_NOTIFICATION_DELIVERY = "mail.notificationDelivery"
|
||||
CAPABILITY_MAIL_BOUNCE_PROCESSING = "mail.bounce_processing"
|
||||
CAPABILITY_MAIL_POSTBOX_BRIDGE = "mail.postbox_bridge"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -56,6 +57,7 @@ class MailDeliveryOutboxProvider(Protocol):
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
limit: int = 250,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
@@ -80,6 +82,28 @@ class MailBounceObservationRef:
|
||||
evidence: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MailPostboxBridgeRequest:
|
||||
tenant_id: str
|
||||
target: object
|
||||
profile_id: str
|
||||
folder: str
|
||||
uid: str
|
||||
uidvalidity: str
|
||||
raw_message: bytes
|
||||
classification: str = "internal"
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MailPostboxBridgeResult:
|
||||
postbox_id: str
|
||||
message_id: str
|
||||
delivery_id: str
|
||||
duplicate: bool
|
||||
source_digest: str
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class MailBounceProcessingProvider(Protocol):
|
||||
"""Mail-owned DSN ingestion and durable correlation boundary."""
|
||||
@@ -115,6 +139,17 @@ class MailBounceProcessingProvider(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class MailPostboxBridgeProvider(Protocol):
|
||||
"""Translate one immutable Mail observation into Postbox delivery."""
|
||||
|
||||
def bridge_message(
|
||||
self,
|
||||
session: object,
|
||||
request: MailPostboxBridgeRequest,
|
||||
) -> MailPostboxBridgeResult: ...
|
||||
|
||||
|
||||
def notification_mail_delivery_provider(
|
||||
registry: object | None,
|
||||
) -> NotificationMailDeliveryProvider | None:
|
||||
@@ -149,3 +184,21 @@ def mail_bounce_processing_provider(
|
||||
"MailBounceProcessingProvider"
|
||||
)
|
||||
return provider
|
||||
|
||||
|
||||
def mail_postbox_bridge_provider(
|
||||
registry: object | None,
|
||||
) -> MailPostboxBridgeProvider | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not registry.has_capability(CAPABILITY_MAIL_POSTBOX_BRIDGE)
|
||||
):
|
||||
return None
|
||||
provider = registry.require_capability(CAPABILITY_MAIL_POSTBOX_BRIDGE)
|
||||
if not isinstance(provider, MailPostboxBridgeProvider):
|
||||
raise TypeError(
|
||||
"mail.postbox_bridge provider does not implement "
|
||||
"MailPostboxBridgeProvider"
|
||||
)
|
||||
return provider
|
||||
|
||||
@@ -0,0 +1,846 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Iterable, Iterator, Mapping
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from threading import RLock
|
||||
from time import monotonic
|
||||
from typing import Any, Literal
|
||||
|
||||
from govoplan_core.core.modules import ModuleManifest
|
||||
|
||||
|
||||
MODULE_ENTITLEMENTS_KEY = "module_entitlements"
|
||||
MODULE_ENTITLEMENT_SCHEMA_VERSION = 1
|
||||
TENANT_PROTECTED_MODULES = ("access", "admin")
|
||||
|
||||
|
||||
class ModuleEntitlementError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class ModuleEntitlementConflict(ModuleEntitlementError):
|
||||
pass
|
||||
|
||||
|
||||
class ModuleEntitlementResolutionError(ModuleEntitlementError):
|
||||
pass
|
||||
|
||||
|
||||
class TenantModuleUnavailable(ModuleEntitlementError):
|
||||
def __init__(self, admission: "TenantModuleAdmission") -> None:
|
||||
self.admission = admission
|
||||
super().__init__(admission.reason)
|
||||
|
||||
|
||||
class TenantModuleOperatorActionRequired(ModuleEntitlementError):
|
||||
def __init__(self, admission: "TenantModuleAdmission") -> None:
|
||||
self.admission = admission
|
||||
super().__init__(admission.reason)
|
||||
|
||||
|
||||
TenantWorkState = Literal["interactive", "new", "accepted"]
|
||||
TenantAdmissionDisposition = Literal[
|
||||
"allowed",
|
||||
"rejected",
|
||||
"operator_action_required",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantModuleItem:
|
||||
id: str
|
||||
name: str
|
||||
dependencies: tuple[str, ...]
|
||||
runtime_active: bool
|
||||
availability: str
|
||||
selected: bool
|
||||
effective: bool
|
||||
forced: bool
|
||||
derived_dependency: bool
|
||||
tenant_can_toggle: bool
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantModuleEntitlementState:
|
||||
revision: int
|
||||
configured: bool
|
||||
available_modules: tuple[str, ...]
|
||||
forced_modules: tuple[str, ...]
|
||||
selected_modules: tuple[str, ...]
|
||||
effective_modules: tuple[str, ...]
|
||||
derived_dependencies: tuple[str, ...]
|
||||
modules: tuple[TenantModuleItem, ...]
|
||||
diagnostics: tuple[dict[str, str], ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantModuleAdmission:
|
||||
tenant_id: str
|
||||
module_id: str
|
||||
revision: int
|
||||
work_state: TenantWorkState
|
||||
allowed: bool
|
||||
disposition: TenantAdmissionDisposition
|
||||
reason: str
|
||||
|
||||
def payload(self) -> dict[str, object]:
|
||||
return {
|
||||
"tenant_id": self.tenant_id,
|
||||
"module_id": self.module_id,
|
||||
"entitlement_revision": self.revision,
|
||||
"work_state": self.work_state,
|
||||
"allowed": self.allowed,
|
||||
"disposition": self.disposition,
|
||||
"reason": self.reason,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _CachedTenantEntitlement:
|
||||
expires_at: float
|
||||
tenant_active: bool
|
||||
state: TenantModuleEntitlementState
|
||||
|
||||
|
||||
class TenantModuleEntitlementResolver:
|
||||
"""Resolve tenant-effective modules with bounded process-local caching.
|
||||
|
||||
Cache entries are explicitly invalidated by local mutations and expire
|
||||
quickly so changes made on another application node become authoritative
|
||||
without requiring a database lookup for every capability call.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
registry: object,
|
||||
*,
|
||||
ttl_seconds: float = 5.0,
|
||||
max_entries: int = 2048,
|
||||
) -> None:
|
||||
self._registry = registry
|
||||
self._ttl_seconds = max(0.0, min(float(ttl_seconds), 300.0))
|
||||
self._max_entries = max(1, int(max_entries))
|
||||
self._cache: OrderedDict[str, _CachedTenantEntitlement] = OrderedDict()
|
||||
self._lock = RLock()
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
session: object,
|
||||
tenant_id: str,
|
||||
) -> TenantModuleEntitlementState:
|
||||
normalized_tenant_id = str(tenant_id or "").strip()
|
||||
if not normalized_tenant_id:
|
||||
raise ModuleEntitlementResolutionError("Tenant id is required")
|
||||
|
||||
cached = self._cached(normalized_tenant_id)
|
||||
if cached is not None:
|
||||
if not cached.tenant_active:
|
||||
raise ModuleEntitlementResolutionError(
|
||||
f"Tenant is inactive: {normalized_tenant_id}"
|
||||
)
|
||||
return cached.state
|
||||
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
getter = getattr(session, "get", None)
|
||||
if not callable(getter):
|
||||
raise ModuleEntitlementResolutionError(
|
||||
"Tenant module entitlement resolution requires a database session"
|
||||
)
|
||||
tenant = getter(Tenant, normalized_tenant_id)
|
||||
if tenant is None:
|
||||
raise ModuleEntitlementResolutionError(
|
||||
f"Tenant is unavailable: {normalized_tenant_id}"
|
||||
)
|
||||
state = self._state_from_settings(getattr(tenant, "settings", None))
|
||||
tenant_active = bool(getattr(tenant, "is_active", False))
|
||||
self._store(normalized_tenant_id, tenant_active=tenant_active, state=state)
|
||||
if not tenant_active:
|
||||
raise ModuleEntitlementResolutionError(
|
||||
f"Tenant is inactive: {normalized_tenant_id}"
|
||||
)
|
||||
return state
|
||||
|
||||
def admission(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
module_id: str,
|
||||
work_state: TenantWorkState = "interactive",
|
||||
) -> TenantModuleAdmission:
|
||||
if work_state not in {"interactive", "new", "accepted"}:
|
||||
raise ModuleEntitlementError(f"Unsupported tenant work state: {work_state}")
|
||||
normalized_module_id = str(module_id or "").strip()
|
||||
if not normalized_module_id:
|
||||
raise ModuleEntitlementError("Module id is required")
|
||||
state = self.resolve(session, tenant_id)
|
||||
allowed = normalized_module_id in state.effective_modules
|
||||
if allowed:
|
||||
return TenantModuleAdmission(
|
||||
tenant_id=str(tenant_id),
|
||||
module_id=normalized_module_id,
|
||||
revision=state.revision,
|
||||
work_state=work_state,
|
||||
allowed=True,
|
||||
disposition="allowed",
|
||||
reason="The module is effective for this tenant.",
|
||||
)
|
||||
accepted = work_state == "accepted"
|
||||
return TenantModuleAdmission(
|
||||
tenant_id=str(tenant_id),
|
||||
module_id=normalized_module_id,
|
||||
revision=state.revision,
|
||||
work_state=work_state,
|
||||
allowed=False,
|
||||
disposition=(
|
||||
"operator_action_required" if accepted else "rejected"
|
||||
),
|
||||
reason=(
|
||||
"Accepted durable work was preserved because the owning module "
|
||||
"is no longer effective for this tenant; an operator must resume "
|
||||
"the module or resolve the work explicitly."
|
||||
if accepted
|
||||
else "The module is not effective for this tenant."
|
||||
),
|
||||
)
|
||||
|
||||
def require(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
module_id: str,
|
||||
work_state: TenantWorkState = "interactive",
|
||||
) -> TenantModuleAdmission:
|
||||
admission = self.admission(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
module_id=module_id,
|
||||
work_state=work_state,
|
||||
)
|
||||
if admission.allowed:
|
||||
return admission
|
||||
if admission.disposition == "operator_action_required":
|
||||
raise TenantModuleOperatorActionRequired(admission)
|
||||
raise TenantModuleUnavailable(admission)
|
||||
|
||||
def effective_tenant_ids(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
module_id: str,
|
||||
) -> tuple[str, ...]:
|
||||
"""Return active tenants that may admit new work for one module."""
|
||||
|
||||
return tuple(
|
||||
admission.tenant_id
|
||||
for admission in self.active_tenant_admissions(
|
||||
session,
|
||||
module_id=module_id,
|
||||
work_state="new",
|
||||
)
|
||||
if admission.allowed
|
||||
)
|
||||
|
||||
def active_tenant_admissions(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
module_id: str,
|
||||
work_state: TenantWorkState = "new",
|
||||
) -> tuple[TenantModuleAdmission, ...]:
|
||||
"""Resolve one admission per active tenant with a single DB query."""
|
||||
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
query = getattr(session, "query", None)
|
||||
if not callable(query):
|
||||
raise ModuleEntitlementResolutionError(
|
||||
"Tenant module entitlement resolution requires a database session"
|
||||
)
|
||||
tenants = (
|
||||
query(Tenant)
|
||||
.filter(Tenant.is_active.is_(True))
|
||||
.order_by(Tenant.id.asc())
|
||||
.all()
|
||||
)
|
||||
admissions: list[TenantModuleAdmission] = []
|
||||
for tenant in tenants:
|
||||
state = self._state_from_settings(getattr(tenant, "settings", None))
|
||||
self._store(tenant.id, tenant_active=True, state=state)
|
||||
allowed = module_id in state.effective_modules
|
||||
accepted = work_state == "accepted"
|
||||
admissions.append(
|
||||
TenantModuleAdmission(
|
||||
tenant_id=tenant.id,
|
||||
module_id=module_id,
|
||||
revision=state.revision,
|
||||
work_state=work_state,
|
||||
allowed=allowed,
|
||||
disposition=(
|
||||
"allowed"
|
||||
if allowed
|
||||
else "operator_action_required"
|
||||
if accepted
|
||||
else "rejected"
|
||||
),
|
||||
reason=(
|
||||
"The module is effective for this tenant."
|
||||
if allowed
|
||||
else "Accepted durable work was preserved because the owning module is no longer effective for this tenant; an operator must resume the module or resolve the work explicitly."
|
||||
if accepted
|
||||
else "The module is not effective for this tenant."
|
||||
),
|
||||
)
|
||||
)
|
||||
return tuple(admissions)
|
||||
|
||||
def invalidate(self, tenant_id: str | None = None) -> None:
|
||||
with self._lock:
|
||||
if tenant_id is None:
|
||||
self._cache.clear()
|
||||
else:
|
||||
self._cache.pop(str(tenant_id), None)
|
||||
|
||||
def _state_from_settings(
|
||||
self,
|
||||
settings: Mapping[str, object] | None,
|
||||
) -> TenantModuleEntitlementState:
|
||||
manifests_method = getattr(self._registry, "manifests", None)
|
||||
if not callable(manifests_method):
|
||||
raise ModuleEntitlementResolutionError(
|
||||
"Tenant module entitlement resolver has no platform registry"
|
||||
)
|
||||
manifests = {manifest.id: manifest for manifest in manifests_method()}
|
||||
return tenant_module_entitlement_state(
|
||||
settings,
|
||||
manifests,
|
||||
runtime_active_modules=manifests,
|
||||
)
|
||||
|
||||
def _cached(self, tenant_id: str) -> _CachedTenantEntitlement | None:
|
||||
now = monotonic()
|
||||
with self._lock:
|
||||
cached = self._cache.get(tenant_id)
|
||||
if cached is None:
|
||||
return None
|
||||
if cached.expires_at <= now:
|
||||
self._cache.pop(tenant_id, None)
|
||||
return None
|
||||
self._cache.move_to_end(tenant_id)
|
||||
return cached
|
||||
|
||||
def _store(
|
||||
self,
|
||||
tenant_id: str,
|
||||
*,
|
||||
tenant_active: bool,
|
||||
state: TenantModuleEntitlementState,
|
||||
) -> None:
|
||||
if self._ttl_seconds <= 0:
|
||||
return
|
||||
with self._lock:
|
||||
self._cache[str(tenant_id)] = _CachedTenantEntitlement(
|
||||
expires_at=monotonic() + self._ttl_seconds,
|
||||
tenant_active=tenant_active,
|
||||
state=state,
|
||||
)
|
||||
self._cache.move_to_end(str(tenant_id))
|
||||
while len(self._cache) > self._max_entries:
|
||||
self._cache.popitem(last=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantExecutionContext:
|
||||
resolver: TenantModuleEntitlementResolver
|
||||
session: object
|
||||
tenant_id: str
|
||||
work_state: TenantWorkState
|
||||
|
||||
def require_module(self, module_id: str) -> TenantModuleAdmission:
|
||||
return self.resolver.require(
|
||||
self.session,
|
||||
tenant_id=self.tenant_id,
|
||||
module_id=module_id,
|
||||
work_state=self.work_state,
|
||||
)
|
||||
|
||||
|
||||
_TENANT_EXECUTION_CONTEXT: ContextVar[TenantExecutionContext | None] = ContextVar(
|
||||
"govoplan_tenant_execution_context",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
def current_tenant_execution_context() -> TenantExecutionContext | None:
|
||||
return _TENANT_EXECUTION_CONTEXT.get()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def tenant_execution_scope(
|
||||
resolver: TenantModuleEntitlementResolver,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
work_state: TenantWorkState = "interactive",
|
||||
) -> Iterator[TenantExecutionContext]:
|
||||
context = TenantExecutionContext(
|
||||
resolver=resolver,
|
||||
session=session,
|
||||
tenant_id=str(tenant_id),
|
||||
work_state=work_state,
|
||||
)
|
||||
token = _TENANT_EXECUTION_CONTEXT.set(context)
|
||||
try:
|
||||
yield context
|
||||
finally:
|
||||
_TENANT_EXECUTION_CONTEXT.reset(token)
|
||||
|
||||
|
||||
def tenant_module_entitlement_state(
|
||||
settings: Mapping[str, object] | None,
|
||||
manifests: Mapping[str, ModuleManifest],
|
||||
*,
|
||||
runtime_active_modules: Iterable[str] | None = None,
|
||||
protected_modules: Iterable[str] = TENANT_PROTECTED_MODULES,
|
||||
) -> TenantModuleEntitlementState:
|
||||
module_ids = tuple(sorted(manifests))
|
||||
known = set(module_ids)
|
||||
runtime_active = (
|
||||
known
|
||||
if runtime_active_modules is None
|
||||
else known.intersection(_normalized_ids(runtime_active_modules))
|
||||
)
|
||||
protected = known.intersection(_normalized_ids(protected_modules))
|
||||
raw_document = (settings or {}).get(MODULE_ENTITLEMENTS_KEY)
|
||||
configured = isinstance(raw_document, Mapping)
|
||||
diagnostics: list[dict[str, str]] = []
|
||||
|
||||
if not configured:
|
||||
revision = 0
|
||||
requested_available = set(known)
|
||||
requested_forced = set(protected)
|
||||
requested_selected = set(known)
|
||||
else:
|
||||
document = raw_document
|
||||
revision = _revision(document.get("revision"), diagnostics)
|
||||
system_policy = document.get("system_policy")
|
||||
tenant_selection = document.get("tenant_selection")
|
||||
if not isinstance(system_policy, Mapping) or not isinstance(
|
||||
tenant_selection, Mapping
|
||||
):
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"module_entitlements.invalid_document",
|
||||
"The tenant module entitlement document is malformed and was restricted to protected modules.",
|
||||
)
|
||||
)
|
||||
requested_available = set(protected)
|
||||
requested_forced = set(protected)
|
||||
requested_selected = set()
|
||||
else:
|
||||
requested_available = _configured_ids(
|
||||
system_policy.get("available_modules"),
|
||||
field="system_policy.available_modules",
|
||||
known=known,
|
||||
fallback=protected,
|
||||
diagnostics=diagnostics,
|
||||
)
|
||||
requested_forced = _configured_ids(
|
||||
system_policy.get("forced_modules"),
|
||||
field="system_policy.forced_modules",
|
||||
known=known,
|
||||
fallback=protected,
|
||||
diagnostics=diagnostics,
|
||||
)
|
||||
requested_selected = _configured_ids(
|
||||
tenant_selection.get("enabled_modules"),
|
||||
field="tenant_selection.enabled_modules",
|
||||
known=known,
|
||||
fallback=(),
|
||||
diagnostics=diagnostics,
|
||||
)
|
||||
|
||||
available, missing_available = _dependency_closure(
|
||||
requested_available | requested_forced | protected,
|
||||
manifests,
|
||||
)
|
||||
forced, missing_forced = _dependency_closure(
|
||||
requested_forced | protected,
|
||||
manifests,
|
||||
)
|
||||
selected = requested_selected.intersection(available)
|
||||
effective_candidates, missing_selected = _dependency_closure(
|
||||
selected | forced,
|
||||
manifests,
|
||||
)
|
||||
effective_candidates.intersection_update(available)
|
||||
effective = effective_candidates.intersection(runtime_active)
|
||||
derived = effective_candidates - selected - forced
|
||||
|
||||
for module_id in sorted(
|
||||
missing_available | missing_forced | missing_selected
|
||||
):
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"module_entitlements.missing_dependency",
|
||||
f"A selected module requires unavailable dependency {module_id}.",
|
||||
)
|
||||
)
|
||||
|
||||
items: list[TenantModuleItem] = []
|
||||
for module_id in module_ids:
|
||||
manifest = manifests[module_id]
|
||||
is_available = module_id in available
|
||||
is_forced = module_id in forced
|
||||
is_selected = module_id in selected
|
||||
is_derived = module_id in derived
|
||||
is_runtime_active = module_id in runtime_active
|
||||
is_effective = module_id in effective
|
||||
reason: str | None = None
|
||||
if not is_available:
|
||||
reason = "Unavailable by system policy."
|
||||
elif is_forced:
|
||||
reason = "Required by system policy or a protected platform dependency."
|
||||
elif is_derived:
|
||||
reason = "Required by another selected module."
|
||||
elif not is_runtime_active and (is_selected or is_forced):
|
||||
reason = "Selected for this tenant, but the module is not active in the deployment."
|
||||
items.append(
|
||||
TenantModuleItem(
|
||||
id=module_id,
|
||||
name=manifest.name,
|
||||
dependencies=tuple(manifest.dependencies),
|
||||
runtime_active=is_runtime_active,
|
||||
availability=(
|
||||
"forced" if is_forced else "available" if is_available else "unavailable"
|
||||
),
|
||||
selected=is_selected,
|
||||
effective=is_effective,
|
||||
forced=is_forced,
|
||||
derived_dependency=is_derived,
|
||||
tenant_can_toggle=is_available and not is_forced and not is_derived,
|
||||
reason=reason,
|
||||
)
|
||||
)
|
||||
|
||||
return TenantModuleEntitlementState(
|
||||
revision=revision,
|
||||
configured=configured,
|
||||
available_modules=tuple(sorted(available)),
|
||||
forced_modules=tuple(sorted(forced)),
|
||||
selected_modules=tuple(sorted(selected)),
|
||||
effective_modules=tuple(sorted(effective)),
|
||||
derived_dependencies=tuple(sorted(derived)),
|
||||
modules=tuple(items),
|
||||
diagnostics=tuple(diagnostics),
|
||||
)
|
||||
|
||||
|
||||
def update_system_tenant_module_policy(
|
||||
settings: Mapping[str, object] | None,
|
||||
manifests: Mapping[str, ModuleManifest],
|
||||
*,
|
||||
available_modules: Iterable[str],
|
||||
forced_modules: Iterable[str],
|
||||
enabled_modules: Iterable[str],
|
||||
expected_revision: int | None,
|
||||
runtime_active_modules: Iterable[str] | None = None,
|
||||
protected_modules: Iterable[str] = TENANT_PROTECTED_MODULES,
|
||||
) -> tuple[dict[str, object], TenantModuleEntitlementState]:
|
||||
current = tenant_module_entitlement_state(
|
||||
settings,
|
||||
manifests,
|
||||
runtime_active_modules=runtime_active_modules,
|
||||
protected_modules=protected_modules,
|
||||
)
|
||||
_check_revision(current.revision, expected_revision)
|
||||
known = set(manifests)
|
||||
available_requested = _validated_requested_ids(
|
||||
available_modules, known=known, field="available_modules"
|
||||
)
|
||||
forced_requested = _validated_requested_ids(
|
||||
forced_modules, known=known, field="forced_modules"
|
||||
)
|
||||
enabled_requested = _validated_requested_ids(
|
||||
enabled_modules, known=known, field="enabled_modules"
|
||||
)
|
||||
protected = known.intersection(_normalized_ids(protected_modules))
|
||||
available, missing = _dependency_closure(
|
||||
available_requested | forced_requested | protected,
|
||||
manifests,
|
||||
)
|
||||
forced, forced_missing = _dependency_closure(
|
||||
forced_requested | protected,
|
||||
manifests,
|
||||
)
|
||||
if missing or forced_missing:
|
||||
missing_text = ", ".join(sorted(missing | forced_missing))
|
||||
raise ModuleEntitlementError(
|
||||
f"Module policy references dependencies that are not installed: {missing_text}"
|
||||
)
|
||||
unavailable_enabled = enabled_requested - available
|
||||
if unavailable_enabled:
|
||||
raise ModuleEntitlementError(
|
||||
"Tenant selection contains modules unavailable by system policy: "
|
||||
+ ", ".join(sorted(unavailable_enabled))
|
||||
)
|
||||
_validate_enabled_dependencies(enabled_requested | forced, available, manifests)
|
||||
updated = _write_document(
|
||||
settings,
|
||||
revision=current.revision + 1,
|
||||
available_modules=available,
|
||||
forced_modules=forced,
|
||||
enabled_modules=enabled_requested,
|
||||
)
|
||||
return updated, tenant_module_entitlement_state(
|
||||
updated,
|
||||
manifests,
|
||||
runtime_active_modules=runtime_active_modules,
|
||||
protected_modules=protected_modules,
|
||||
)
|
||||
|
||||
|
||||
def update_tenant_module_selection(
|
||||
settings: Mapping[str, object] | None,
|
||||
manifests: Mapping[str, ModuleManifest],
|
||||
*,
|
||||
enabled_modules: Iterable[str],
|
||||
expected_revision: int | None,
|
||||
runtime_active_modules: Iterable[str] | None = None,
|
||||
protected_modules: Iterable[str] = TENANT_PROTECTED_MODULES,
|
||||
) -> tuple[dict[str, object], TenantModuleEntitlementState]:
|
||||
current = tenant_module_entitlement_state(
|
||||
settings,
|
||||
manifests,
|
||||
runtime_active_modules=runtime_active_modules,
|
||||
protected_modules=protected_modules,
|
||||
)
|
||||
_check_revision(current.revision, expected_revision)
|
||||
enabled = _validated_requested_ids(
|
||||
enabled_modules,
|
||||
known=set(manifests),
|
||||
field="enabled_modules",
|
||||
)
|
||||
unavailable = enabled - set(current.available_modules)
|
||||
if unavailable:
|
||||
raise ModuleEntitlementError(
|
||||
"Tenant selection contains modules unavailable by system policy: "
|
||||
+ ", ".join(sorted(unavailable))
|
||||
)
|
||||
_validate_enabled_dependencies(
|
||||
enabled | set(current.forced_modules),
|
||||
set(current.available_modules),
|
||||
manifests,
|
||||
)
|
||||
updated = _write_document(
|
||||
settings,
|
||||
revision=current.revision + 1,
|
||||
available_modules=current.available_modules,
|
||||
forced_modules=current.forced_modules,
|
||||
enabled_modules=enabled,
|
||||
)
|
||||
return updated, tenant_module_entitlement_state(
|
||||
updated,
|
||||
manifests,
|
||||
runtime_active_modules=runtime_active_modules,
|
||||
protected_modules=protected_modules,
|
||||
)
|
||||
|
||||
|
||||
def module_entitlement_payload(
|
||||
tenant_id: str,
|
||||
state: TenantModuleEntitlementState,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"tenant_id": tenant_id,
|
||||
"revision": state.revision,
|
||||
"configured": state.configured,
|
||||
"available_modules": list(state.available_modules),
|
||||
"forced_modules": list(state.forced_modules),
|
||||
"selected_modules": list(state.selected_modules),
|
||||
"effective_modules": list(state.effective_modules),
|
||||
"derived_dependencies": list(state.derived_dependencies),
|
||||
"modules": [
|
||||
{
|
||||
"id": item.id,
|
||||
"name": item.name,
|
||||
"dependencies": list(item.dependencies),
|
||||
"runtime_active": item.runtime_active,
|
||||
"availability": item.availability,
|
||||
"selected": item.selected,
|
||||
"effective": item.effective,
|
||||
"forced": item.forced,
|
||||
"derived_dependency": item.derived_dependency,
|
||||
"tenant_can_toggle": item.tenant_can_toggle,
|
||||
"reason": item.reason,
|
||||
}
|
||||
for item in state.modules
|
||||
],
|
||||
"diagnostics": [dict(item) for item in state.diagnostics],
|
||||
}
|
||||
|
||||
|
||||
def _write_document(
|
||||
settings: Mapping[str, object] | None,
|
||||
*,
|
||||
revision: int,
|
||||
available_modules: Iterable[str],
|
||||
forced_modules: Iterable[str],
|
||||
enabled_modules: Iterable[str],
|
||||
) -> dict[str, object]:
|
||||
updated = dict(settings or {})
|
||||
updated[MODULE_ENTITLEMENTS_KEY] = {
|
||||
"schema_version": MODULE_ENTITLEMENT_SCHEMA_VERSION,
|
||||
"revision": revision,
|
||||
"system_policy": {
|
||||
"available_modules": sorted(set(available_modules)),
|
||||
"forced_modules": sorted(set(forced_modules)),
|
||||
},
|
||||
"tenant_selection": {
|
||||
"enabled_modules": sorted(set(enabled_modules)),
|
||||
},
|
||||
}
|
||||
return updated
|
||||
|
||||
|
||||
def _validate_enabled_dependencies(
|
||||
enabled: set[str],
|
||||
available: set[str],
|
||||
manifests: Mapping[str, ModuleManifest],
|
||||
) -> None:
|
||||
closure, missing = _dependency_closure(enabled, manifests)
|
||||
if missing:
|
||||
raise ModuleEntitlementError(
|
||||
"Selected modules require dependencies that are not installed: "
|
||||
+ ", ".join(sorted(missing))
|
||||
)
|
||||
unavailable = closure - available
|
||||
if unavailable:
|
||||
raise ModuleEntitlementError(
|
||||
"Selected modules require dependencies unavailable by system policy: "
|
||||
+ ", ".join(sorted(unavailable))
|
||||
)
|
||||
|
||||
|
||||
def _dependency_closure(
|
||||
requested: Iterable[str],
|
||||
manifests: Mapping[str, ModuleManifest],
|
||||
) -> tuple[set[str], set[str]]:
|
||||
closure: set[str] = set()
|
||||
missing: set[str] = set()
|
||||
pending = list(_normalized_ids(requested))
|
||||
while pending:
|
||||
module_id = pending.pop()
|
||||
if module_id in closure:
|
||||
continue
|
||||
manifest = manifests.get(module_id)
|
||||
if manifest is None:
|
||||
missing.add(module_id)
|
||||
continue
|
||||
closure.add(module_id)
|
||||
pending.extend(manifest.dependencies)
|
||||
return closure, missing
|
||||
|
||||
|
||||
def _configured_ids(
|
||||
value: object,
|
||||
*,
|
||||
field: str,
|
||||
known: set[str],
|
||||
fallback: Iterable[str],
|
||||
diagnostics: list[dict[str, str]],
|
||||
) -> set[str]:
|
||||
if not isinstance(value, list | tuple):
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"module_entitlements.invalid_field",
|
||||
f"{field} is malformed and was evaluated with a restrictive fallback.",
|
||||
)
|
||||
)
|
||||
return set(fallback)
|
||||
values = _normalized_ids(value)
|
||||
unknown = values - known
|
||||
if unknown:
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"module_entitlements.unknown_module",
|
||||
f"{field} references unknown modules: {', '.join(sorted(unknown))}.",
|
||||
)
|
||||
)
|
||||
return values.intersection(known)
|
||||
|
||||
|
||||
def _validated_requested_ids(
|
||||
values: Iterable[str],
|
||||
*,
|
||||
known: set[str],
|
||||
field: str,
|
||||
) -> set[str]:
|
||||
normalized = _normalized_ids(values)
|
||||
unknown = normalized - known
|
||||
if unknown:
|
||||
raise ModuleEntitlementError(
|
||||
f"{field} contains unknown modules: {', '.join(sorted(unknown))}"
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalized_ids(values: Iterable[object]) -> set[str]:
|
||||
return {
|
||||
clean
|
||||
for value in values
|
||||
if (clean := str(value).strip())
|
||||
}
|
||||
|
||||
|
||||
def _revision(value: object, diagnostics: list[dict[str, str]]) -> int:
|
||||
if isinstance(value, int) and value >= 0:
|
||||
return value
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"module_entitlements.invalid_revision",
|
||||
"The module entitlement revision is invalid; concurrent updates will require a reload.",
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _check_revision(current: int, expected: int | None) -> None:
|
||||
if expected is not None and expected != current:
|
||||
raise ModuleEntitlementConflict(
|
||||
f"Module entitlement revision changed from {expected} to {current}; reload before saving."
|
||||
)
|
||||
|
||||
|
||||
def _diagnostic(code: str, message: str) -> dict[str, str]:
|
||||
return {"code": code, "message": message, "severity": "warning"}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MODULE_ENTITLEMENTS_KEY",
|
||||
"MODULE_ENTITLEMENT_SCHEMA_VERSION",
|
||||
"TENANT_PROTECTED_MODULES",
|
||||
"ModuleEntitlementConflict",
|
||||
"ModuleEntitlementError",
|
||||
"ModuleEntitlementResolutionError",
|
||||
"TenantExecutionContext",
|
||||
"TenantModuleAdmission",
|
||||
"TenantModuleEntitlementResolver",
|
||||
"TenantModuleEntitlementState",
|
||||
"TenantModuleItem",
|
||||
"TenantModuleOperatorActionRequired",
|
||||
"TenantModuleUnavailable",
|
||||
"TenantWorkState",
|
||||
"current_tenant_execution_context",
|
||||
"module_entitlement_payload",
|
||||
"tenant_execution_scope",
|
||||
"tenant_module_entitlement_state",
|
||||
"update_system_tenant_module_policy",
|
||||
"update_tenant_module_selection",
|
||||
]
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterable, Mapping
|
||||
from contextlib import AbstractContextManager, closing
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import UTC, datetime
|
||||
from importlib import metadata
|
||||
import hashlib
|
||||
@@ -18,6 +18,7 @@ import sqlite3
|
||||
import stat
|
||||
import subprocess # nosec B404 - installer commands are structured and policy-validated before execution.
|
||||
import sys
|
||||
import tempfile
|
||||
import tomllib
|
||||
from typing import Any, Literal
|
||||
import time
|
||||
@@ -27,6 +28,12 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
||||
from govoplan_core.core.events import current_event_trace
|
||||
from govoplan_core.core.module_lifecycle_recovery import (
|
||||
ModuleLifecycleRecovery,
|
||||
ModuleLifecycleRecoveryError,
|
||||
begin_module_installer_recovery,
|
||||
canonical_sha256,
|
||||
)
|
||||
from govoplan_core.core.module_management import (
|
||||
PROTECTED_MODULES,
|
||||
ModuleInstallPlan,
|
||||
@@ -56,6 +63,7 @@ MIGRATION_TASK_PHASES = (
|
||||
MIGRATION_TASK_MUTATING_PHASES = {"pre_migration_prepare", "post_migration_backfill"}
|
||||
MIGRATION_TASK_REVIEW_SAFETY = {"requires_review", "forward_only", "destructive"}
|
||||
MIGRATION_TASK_BLOCKING_SAFETY = {"forward_only", "destructive"}
|
||||
MAX_PACKAGE_ARTIFACT_BYTES = 512 * 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -268,6 +276,11 @@ class ModuleInstallerRunResult:
|
||||
return_code: int = 0
|
||||
error: str | None = None
|
||||
rollback: dict[str, object] | None = None
|
||||
recovery: ModuleLifecycleRecovery | None = field(
|
||||
default=None,
|
||||
repr=False,
|
||||
compare=False,
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
@@ -293,6 +306,7 @@ class _ModuleInstallRunState:
|
||||
result_commands: tuple[str, ...]
|
||||
record_redactions: tuple[str, ...]
|
||||
record: dict[str, Any]
|
||||
recovery: ModuleLifecycleRecovery | None = None
|
||||
|
||||
|
||||
def default_installer_runtime_dir(database_url: str | None = None, *, cwd: Path | None = None) -> Path:
|
||||
@@ -452,14 +466,20 @@ def _package_target_action_preflight_issues(
|
||||
"Python installs must include the distribution package name so rollback can uninstall newly added packages.",
|
||||
item.module_id,
|
||||
))
|
||||
if item.python_ref and not _looks_pinned_dependency_ref(item.python_ref):
|
||||
if item.python_ref and not (
|
||||
_looks_pinned_dependency_ref(item.python_ref)
|
||||
or _artifact_ref_is_digest_pinned(item, "python", item.python_ref)
|
||||
):
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"blocker",
|
||||
"unpinned_python_ref",
|
||||
"Python install refs must be pinned to an exact version or tagged git ref.",
|
||||
item.module_id,
|
||||
))
|
||||
if item.webui_ref and not _looks_pinned_dependency_ref(item.webui_ref):
|
||||
if item.webui_ref and not (
|
||||
_looks_pinned_dependency_ref(item.webui_ref)
|
||||
or _artifact_ref_is_digest_pinned(item, "webui", item.webui_ref)
|
||||
):
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"blocker",
|
||||
"unpinned_webui_ref",
|
||||
@@ -469,6 +489,23 @@ def _package_target_action_preflight_issues(
|
||||
return tuple(issues)
|
||||
|
||||
|
||||
def _artifact_ref_is_digest_pinned(
|
||||
item: ModuleInstallPlanItem,
|
||||
kind: str,
|
||||
package_ref: str,
|
||||
) -> bool:
|
||||
metadata = _artifact_metadata(item.artifact_integrity, kind)
|
||||
if metadata is None:
|
||||
return False
|
||||
expected_ref = _artifact_text(metadata, "ref") or _artifact_text(metadata, "expected_ref")
|
||||
sha256 = _artifact_text(metadata, "sha256")
|
||||
return bool(
|
||||
expected_ref == package_ref
|
||||
and sha256
|
||||
and re.fullmatch(r"[0-9a-f]{64}", sha256.lower())
|
||||
)
|
||||
|
||||
|
||||
def _frontend_rebuild_preflight_issues(
|
||||
*,
|
||||
frontend_rebuild_required: bool,
|
||||
@@ -503,9 +540,11 @@ def run_module_install_plan(
|
||||
remove_uninstalled_modules_from_desired: bool = True,
|
||||
dry_run: bool = False,
|
||||
request_context: Mapping[str, object] | None = None,
|
||||
finalize_recovery: bool = True,
|
||||
) -> ModuleInstallerRunResult:
|
||||
maintenance_mode = saved_maintenance_mode(session)
|
||||
effective_runtime_dir = runtime_dir or default_installer_runtime_dir(database_url)
|
||||
effective_plan = plan
|
||||
preflight = module_install_preflight(
|
||||
plan=plan,
|
||||
available=available,
|
||||
@@ -519,8 +558,30 @@ def run_module_install_plan(
|
||||
if not preflight.allowed:
|
||||
raise ModuleInstallerError("Install preflight is blocked: " + "; ".join(issue.message for issue in preflight.issues if issue.severity == "blocker"))
|
||||
|
||||
if not dry_run:
|
||||
effective_plan = acquire_catalog_package_artifacts(
|
||||
plan,
|
||||
runtime_dir=effective_runtime_dir,
|
||||
)
|
||||
preflight = module_install_preflight(
|
||||
plan=effective_plan,
|
||||
available=available,
|
||||
current_enabled=current_enabled,
|
||||
desired_enabled=desired_enabled,
|
||||
maintenance_mode=maintenance_mode.enabled,
|
||||
session=session,
|
||||
webui_root=webui_root,
|
||||
runtime_dir=effective_runtime_dir,
|
||||
)
|
||||
if not preflight.allowed:
|
||||
raise ModuleInstallerError(
|
||||
"Install preflight is blocked after artifact acquisition: "
|
||||
+ "; ".join(issue.message for issue in preflight.issues if issue.severity == "blocker")
|
||||
)
|
||||
|
||||
state = _prepare_module_install_run(
|
||||
plan=plan,
|
||||
session=session,
|
||||
plan=effective_plan,
|
||||
preflight=preflight,
|
||||
database_url=database_url,
|
||||
effective_runtime_dir=effective_runtime_dir,
|
||||
@@ -542,7 +603,7 @@ def run_module_install_plan(
|
||||
|
||||
executed, failed_error = _execute_module_install_run(
|
||||
session=session,
|
||||
plan=plan,
|
||||
plan=effective_plan,
|
||||
available=available,
|
||||
effective_runtime_dir=effective_runtime_dir,
|
||||
state=state,
|
||||
@@ -550,8 +611,9 @@ def run_module_install_plan(
|
||||
|
||||
if failed_error is not None:
|
||||
return _failed_module_install_run_result(
|
||||
session=session,
|
||||
state=state,
|
||||
plan=plan,
|
||||
plan=effective_plan,
|
||||
executed=executed,
|
||||
failed_error=failed_error,
|
||||
effective_runtime_dir=effective_runtime_dir,
|
||||
@@ -563,17 +625,19 @@ def run_module_install_plan(
|
||||
|
||||
return _applied_module_install_run_result(
|
||||
session=session,
|
||||
plan=plan,
|
||||
plan=effective_plan,
|
||||
desired_enabled=desired_enabled,
|
||||
activate_installed_modules=activate_installed_modules,
|
||||
remove_uninstalled_modules_from_desired=remove_uninstalled_modules_from_desired,
|
||||
executed=executed,
|
||||
state=state,
|
||||
finalize_recovery=finalize_recovery,
|
||||
)
|
||||
|
||||
|
||||
def _prepare_module_install_run(
|
||||
*,
|
||||
session: Session,
|
||||
plan: ModuleInstallPlan,
|
||||
preflight: ModuleInstallerPreflight,
|
||||
database_url: str,
|
||||
@@ -605,13 +669,39 @@ def _prepare_module_install_run(
|
||||
verify_modules=True,
|
||||
)
|
||||
record_redactions = _installer_secret_redactions(database_url)
|
||||
record = _initial_module_install_record(
|
||||
recovery: ModuleLifecycleRecovery | None = None
|
||||
if not dry_run:
|
||||
try:
|
||||
recovery = begin_module_installer_recovery(
|
||||
session,
|
||||
run_id=run_id,
|
||||
plan=plan,
|
||||
preflight=preflight,
|
||||
commands=commands,
|
||||
record_redactions=record_redactions,
|
||||
snapshot=_snapshot_environment(
|
||||
plan=tuple(item.as_dict() for item in plan.items),
|
||||
command_count=len(commands),
|
||||
migrate_database=migrate_database,
|
||||
destructive_retirement=_destructive_retirement_requested(plan),
|
||||
snapshot_sha256=None,
|
||||
backup_reference=(
|
||||
f"module-installer:{run_id}:database-backup"
|
||||
if _destructive_retirement_requested(plan)
|
||||
else None
|
||||
),
|
||||
request_context_sha256=canonical_sha256(dict(request_context or {})),
|
||||
)
|
||||
recovery.checkpoint(
|
||||
kind="snapshot-started",
|
||||
summary="Installer environment snapshot started before package effects",
|
||||
evidence={
|
||||
"run_id": run_id,
|
||||
"database_backup_expected": bool(
|
||||
migrate_database or _destructive_retirement_requested(plan)
|
||||
),
|
||||
},
|
||||
)
|
||||
except ModuleLifecycleRecoveryError as exc:
|
||||
raise ModuleInstallerError(str(exc)) from exc
|
||||
|
||||
try:
|
||||
snapshot = _snapshot_environment(
|
||||
run_dir,
|
||||
webui_root=webui_root,
|
||||
database_url=database_url,
|
||||
@@ -619,7 +709,32 @@ def _prepare_module_install_run(
|
||||
database_backup_command=database_backup_command,
|
||||
database_restore_command=database_restore_command,
|
||||
database_restore_check_command=database_restore_check_command,
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
if recovery is not None:
|
||||
recovery.unresolved(
|
||||
summary="Installer snapshot preparation failed before package effects",
|
||||
evidence={"snapshot_error_type": type(exc).__name__},
|
||||
outcome_unknown=False,
|
||||
)
|
||||
raise
|
||||
|
||||
if recovery is not None:
|
||||
recovery.checkpoint(
|
||||
kind="snapshot-verified",
|
||||
summary="Installer environment snapshot and backup evidence were verified",
|
||||
evidence={
|
||||
"snapshot_sha256": canonical_sha256(snapshot),
|
||||
**_database_backup_recovery_evidence(snapshot),
|
||||
},
|
||||
)
|
||||
record = _initial_module_install_record(
|
||||
run_id=run_id,
|
||||
plan=plan,
|
||||
preflight=preflight,
|
||||
commands=commands,
|
||||
record_redactions=record_redactions,
|
||||
snapshot=snapshot,
|
||||
build_webui=build_webui,
|
||||
migrate_database=migrate_database,
|
||||
activate_installed_modules=activate_installed_modules,
|
||||
@@ -627,6 +742,8 @@ def _prepare_module_install_run(
|
||||
dry_run=dry_run,
|
||||
request_context=request_context,
|
||||
)
|
||||
if recovery is not None:
|
||||
record["recovery"] = _module_lifecycle_recovery_record(recovery)
|
||||
record_path = run_dir / "record.json"
|
||||
_write_json(record_path, record)
|
||||
return _ModuleInstallRunState(
|
||||
@@ -637,6 +754,7 @@ def _prepare_module_install_run(
|
||||
result_commands=_command_displays(commands, redactions=record_redactions),
|
||||
record_redactions=record_redactions,
|
||||
record=record,
|
||||
recovery=recovery,
|
||||
)
|
||||
|
||||
|
||||
@@ -673,6 +791,40 @@ def _initial_module_install_record(
|
||||
return record
|
||||
|
||||
|
||||
def _module_lifecycle_recovery_record(
|
||||
recovery: ModuleLifecycleRecovery,
|
||||
*,
|
||||
status: str = "running",
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"operation_id": recovery.operation_id,
|
||||
"operation_type": recovery.operation_type,
|
||||
"mode": recovery.mode.value,
|
||||
"plan_sha256": recovery.plan_sha256,
|
||||
"replayed": recovery.replayed,
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
def _database_backup_recovery_evidence(
|
||||
snapshot: Mapping[str, object],
|
||||
) -> dict[str, object]:
|
||||
backup = snapshot.get("database_backup")
|
||||
if not isinstance(backup, Mapping):
|
||||
return {"database_backup_present": False}
|
||||
sha256 = str(backup.get("artifact_sha256") or "").strip()
|
||||
return {
|
||||
"database_backup_present": True,
|
||||
"database_backup_type": str(backup.get("type") or "unknown"),
|
||||
"database_backup_sha256": sha256 or "unavailable",
|
||||
"database_backup_size_bytes": int(backup.get("size_bytes") or 0),
|
||||
"database_backup_reference": (
|
||||
f"sha256:{sha256}" if sha256 else "unavailable"
|
||||
),
|
||||
"restore_check_sha256": canonical_sha256(backup.get("restore_check")),
|
||||
}
|
||||
|
||||
|
||||
def _execute_module_install_run(
|
||||
*,
|
||||
session: Session,
|
||||
@@ -685,14 +837,91 @@ def _execute_module_install_run(
|
||||
failed_error: str | None = None
|
||||
with _installer_lock(effective_runtime_dir):
|
||||
try:
|
||||
if state.recovery is not None:
|
||||
state.recovery.checkpoint(
|
||||
kind="effects-starting",
|
||||
summary="Installer acquired local and distributed execution fences",
|
||||
evidence={
|
||||
"command_count": len(state.commands),
|
||||
"destructive_retirement": _destructive_retirement_requested(plan),
|
||||
},
|
||||
)
|
||||
if _destructive_retirement_requested(plan):
|
||||
state.recovery.checkpoint(
|
||||
kind="retirement-effect-started",
|
||||
summary="Destructive module retirement entered its effect boundary",
|
||||
evidence={
|
||||
"retirement_plan_sha256": canonical_sha256(
|
||||
[
|
||||
item.as_dict()
|
||||
for item in plan.items
|
||||
if item.destroy_data
|
||||
]
|
||||
),
|
||||
},
|
||||
effect_started=True,
|
||||
)
|
||||
_execute_module_install_retirements(session=session, plan=plan, available=available, state=state)
|
||||
for command in state.commands:
|
||||
executed.append(_run_module_install_command(command, state=state))
|
||||
for index, command in enumerate(state.commands):
|
||||
if state.recovery is not None:
|
||||
command_record = _command_record(
|
||||
command,
|
||||
redactions=state.record_redactions,
|
||||
)
|
||||
state.recovery.checkpoint(
|
||||
kind="command-effect-started",
|
||||
summary="Installer command entered its effect boundary",
|
||||
evidence={
|
||||
"command_index": index,
|
||||
"command_source": str(command.get("source") or "unknown"),
|
||||
"command_sha256": canonical_sha256(command_record),
|
||||
},
|
||||
effect_started=True,
|
||||
)
|
||||
command_result = _run_module_install_command(command, state=state)
|
||||
executed.append(command_result)
|
||||
if state.recovery is not None:
|
||||
state.recovery.checkpoint(
|
||||
kind="command-result-verified",
|
||||
summary="Installer command returned a conclusive successful result",
|
||||
evidence={
|
||||
"command_index": index,
|
||||
"return_code": int(command_result["return_code"]),
|
||||
"result_sha256": canonical_sha256(command_result),
|
||||
},
|
||||
)
|
||||
state.record["commands"] = executed
|
||||
_write_json(state.record_path, state.record)
|
||||
except Exception as exc:
|
||||
failed_error = _redact_installer_text(str(exc), redactions=state.record_redactions)
|
||||
_rollback_session_after_module_install_error(session, exc)
|
||||
if state.recovery is not None:
|
||||
outcome_unknown = not isinstance(exc, ModuleInstallerError)
|
||||
try:
|
||||
state.recovery.unresolved(
|
||||
summary="Module installer effects did not reach verified completion",
|
||||
evidence={
|
||||
"error_type": type(exc).__name__,
|
||||
"completed_command_count": len(executed),
|
||||
},
|
||||
outcome_unknown=outcome_unknown,
|
||||
)
|
||||
state.record["recovery"] = _module_lifecycle_recovery_record(
|
||||
state.recovery,
|
||||
status=(
|
||||
"outcome_unknown"
|
||||
if outcome_unknown
|
||||
else "recovery_required"
|
||||
if state.recovery.effect_started
|
||||
else "failed"
|
||||
),
|
||||
)
|
||||
except Exception as recovery_exc:
|
||||
state.record["recovery_error"] = type(recovery_exc).__name__
|
||||
failed_error = (
|
||||
f"{failed_error}; recovery ledger transition failed: "
|
||||
f"{type(recovery_exc).__name__}"
|
||||
)
|
||||
return executed, failed_error
|
||||
|
||||
|
||||
@@ -740,6 +969,7 @@ def _rollback_session_after_module_install_error(session: Session, exc: Exceptio
|
||||
|
||||
def _failed_module_install_run_result(
|
||||
*,
|
||||
session: Session,
|
||||
state: _ModuleInstallRunState,
|
||||
plan: ModuleInstallPlan,
|
||||
executed: list[dict[str, object]],
|
||||
@@ -765,6 +995,7 @@ def _failed_module_install_run_result(
|
||||
commands=state.result_commands,
|
||||
return_code=1,
|
||||
error=failed_error,
|
||||
recovery=state.recovery,
|
||||
)
|
||||
rollback = rollback_module_install_run(
|
||||
run_id=state.run_id,
|
||||
@@ -775,6 +1006,30 @@ def _failed_module_install_run_result(
|
||||
database_url=database_url,
|
||||
)
|
||||
_update_run_record(state.record_path, {"destructive_retirement_rollback": rollback.as_dict()})
|
||||
if rollback.return_code == 0 and state.recovery is not None:
|
||||
try:
|
||||
state.recovery.recovered(
|
||||
session,
|
||||
evidence={
|
||||
"rollback_return_code": rollback.return_code,
|
||||
"rollback_sha256": canonical_sha256(rollback.as_dict()),
|
||||
},
|
||||
summary="Verified rollback restored the pre-install module state",
|
||||
)
|
||||
_update_run_record(
|
||||
state.record_path,
|
||||
{
|
||||
"recovery": _module_lifecycle_recovery_record(
|
||||
state.recovery,
|
||||
status="recovered",
|
||||
)
|
||||
},
|
||||
)
|
||||
except Exception as recovery_exc:
|
||||
_update_run_record(
|
||||
state.record_path,
|
||||
{"recovery_error": type(recovery_exc).__name__},
|
||||
)
|
||||
return ModuleInstallerRunResult(
|
||||
run_id=state.run_id,
|
||||
status="rolled-back" if rollback.return_code == 0 else "failed",
|
||||
@@ -783,6 +1038,7 @@ def _failed_module_install_run_result(
|
||||
return_code=1,
|
||||
error=failed_error,
|
||||
rollback=rollback.as_dict(),
|
||||
recovery=state.recovery,
|
||||
)
|
||||
|
||||
|
||||
@@ -795,6 +1051,7 @@ def _applied_module_install_run_result(
|
||||
remove_uninstalled_modules_from_desired: bool,
|
||||
executed: list[dict[str, object]],
|
||||
state: _ModuleInstallRunState,
|
||||
finalize_recovery: bool,
|
||||
) -> ModuleInstallerRunResult:
|
||||
save_module_install_plan(session, tuple(_mark_applied(item) for item in plan.items))
|
||||
if activate_installed_modules or remove_uninstalled_modules_from_desired:
|
||||
@@ -806,14 +1063,50 @@ def _applied_module_install_run_result(
|
||||
)
|
||||
save_desired_enabled_modules(session, next_desired)
|
||||
state.record["desired_enabled_after"] = list(next_desired)
|
||||
recovery_evidence = {
|
||||
"command_count": len(executed),
|
||||
"command_results_sha256": canonical_sha256(executed),
|
||||
"desired_graph_sha256": canonical_sha256(
|
||||
state.record.get("desired_enabled_after", list(desired_enabled))
|
||||
),
|
||||
"plan_projection_sha256": canonical_sha256(
|
||||
[item.as_dict() for item in plan.items]
|
||||
),
|
||||
}
|
||||
if state.recovery is not None and finalize_recovery:
|
||||
state.recovery.succeed(
|
||||
session,
|
||||
evidence=recovery_evidence,
|
||||
commit_projection=True,
|
||||
)
|
||||
recovery_status = "succeeded"
|
||||
else:
|
||||
session.commit()
|
||||
recovery_status = "awaiting_supervisor" if state.recovery is not None else None
|
||||
if state.recovery is not None:
|
||||
state.recovery.checkpoint(
|
||||
kind="local-projection-committed",
|
||||
summary="Package and desired-graph projections await runtime health verification",
|
||||
evidence=recovery_evidence,
|
||||
)
|
||||
state.record.update({
|
||||
"status": "applied",
|
||||
"finished_at": datetime.now(tz=UTC).isoformat(),
|
||||
"commands": executed,
|
||||
})
|
||||
if state.recovery is not None and recovery_status is not None:
|
||||
state.record["recovery"] = _module_lifecycle_recovery_record(
|
||||
state.recovery,
|
||||
status=recovery_status,
|
||||
)
|
||||
_write_json(state.record_path, state.record)
|
||||
return ModuleInstallerRunResult(run_id=state.run_id, status="applied", record_path=state.record_path, commands=state.result_commands)
|
||||
return ModuleInstallerRunResult(
|
||||
run_id=state.run_id,
|
||||
status="applied",
|
||||
record_path=state.record_path,
|
||||
commands=state.result_commands,
|
||||
recovery=state.recovery,
|
||||
)
|
||||
|
||||
|
||||
def supervise_module_install_plan(
|
||||
@@ -864,6 +1157,7 @@ def supervise_module_install_plan(
|
||||
remove_uninstalled_modules_from_desired=remove_uninstalled_modules_from_desired,
|
||||
dry_run=False,
|
||||
request_context=request_context,
|
||||
finalize_recovery=False,
|
||||
)
|
||||
supervisor: dict[str, object] = {
|
||||
"started_at": datetime.now(tz=UTC).isoformat(),
|
||||
@@ -938,6 +1232,27 @@ def supervise_module_install_plan(
|
||||
"status": "ok",
|
||||
"finished_at": datetime.now(tz=UTC).isoformat(),
|
||||
})
|
||||
if result.recovery is not None:
|
||||
result.recovery.succeed(
|
||||
session,
|
||||
evidence={
|
||||
"restart_results_sha256": canonical_sha256(restart_results),
|
||||
"health_results_sha256": canonical_sha256(supervisor.get("health")),
|
||||
"runtime_health_verified": True,
|
||||
},
|
||||
commit_projection=False,
|
||||
)
|
||||
supervisor["recovery_operation_id"] = result.recovery.operation_id
|
||||
supervisor["recovery_status"] = "succeeded"
|
||||
_update_run_record(
|
||||
result.record_path,
|
||||
{
|
||||
"recovery": _module_lifecycle_recovery_record(
|
||||
result.recovery,
|
||||
status="succeeded",
|
||||
)
|
||||
},
|
||||
)
|
||||
_update_run_record(result.record_path, {"supervisor": supervisor})
|
||||
return result
|
||||
|
||||
@@ -1300,9 +1615,11 @@ def _structured_item_commands(
|
||||
webui_changed = False
|
||||
if item.action in PACKAGE_TARGET_ACTIONS:
|
||||
if item.python_ref:
|
||||
commands.append(_structured_command([sys.executable, "-m", "pip", "install", item.python_ref], source="module-plan.python"))
|
||||
python_source = _verified_artifact_install_ref(item, "python") or item.python_ref
|
||||
commands.append(_structured_command([sys.executable, "-m", "pip", "install", python_source], source="module-plan.python"))
|
||||
if item.webui_package and item.webui_ref and webui_root is not None:
|
||||
commands.append(_structured_command([npm_bin, "pkg", "set", f"dependencies.{item.webui_package}={item.webui_ref}"], cwd=webui_root, source="module-plan.webui"))
|
||||
webui_source = _verified_artifact_install_ref(item, "webui") or item.webui_ref
|
||||
commands.append(_structured_command([npm_bin, "pkg", "set", f"dependencies.{item.webui_package}={webui_source}"], cwd=webui_root, source="module-plan.webui"))
|
||||
webui_changed = True
|
||||
elif item.action == "uninstall":
|
||||
if item.python_package:
|
||||
@@ -1313,6 +1630,14 @@ def _structured_item_commands(
|
||||
return tuple(commands), webui_changed
|
||||
|
||||
|
||||
def _verified_artifact_install_ref(item: ModuleInstallPlanItem, kind: str) -> str | None:
|
||||
metadata = _artifact_metadata(item.artifact_integrity, kind)
|
||||
path = _artifact_path(metadata) if metadata is not None else None
|
||||
if path is None:
|
||||
return None
|
||||
return path.as_uri() if kind == "webui" else str(path)
|
||||
|
||||
|
||||
def _structured_webui_followup_commands(
|
||||
*,
|
||||
webui_changed: bool,
|
||||
@@ -1664,9 +1989,7 @@ def _package_catalog_preflight_issues(
|
||||
return ()
|
||||
catalog_items = tuple(item for item in package_items if item.source == "catalog")
|
||||
try:
|
||||
from govoplan_core.core.module_package_catalog import validate_module_package_catalog
|
||||
|
||||
result = validate_module_package_catalog()
|
||||
result = _validate_catalog_for_plan(catalog_items)
|
||||
except Exception as exc:
|
||||
return _catalog_validation_exception_issues(exc, catalog_items=bool(catalog_items))
|
||||
issues = list(_catalog_validation_result_issues(result, catalog_items=bool(catalog_items)))
|
||||
@@ -1674,10 +1997,33 @@ def _package_catalog_preflight_issues(
|
||||
return tuple(issues)
|
||||
issues.extend(_catalog_warning_issues(result))
|
||||
if catalog_items:
|
||||
issues.extend(_catalog_plan_binding_issues(catalog_items, result))
|
||||
issues.extend(_selected_catalog_interface_issues(catalog_items, result, available))
|
||||
return tuple(issues)
|
||||
|
||||
|
||||
def _validate_catalog_for_plan(
|
||||
catalog_items: tuple[ModuleInstallPlanItem, ...],
|
||||
) -> dict[str, object]:
|
||||
from govoplan_core.core.module_package_catalog import (
|
||||
OFFICIAL_MODULE_PACKAGE_CATALOG_URL,
|
||||
validate_module_package_catalog,
|
||||
validate_official_module_package_catalog,
|
||||
)
|
||||
|
||||
configured = validate_module_package_catalog()
|
||||
if configured.get("configured") or not catalog_items:
|
||||
return configured
|
||||
sources = {
|
||||
str(item.catalog.get("source") or "")
|
||||
for item in catalog_items
|
||||
if isinstance(item.catalog, Mapping)
|
||||
}
|
||||
if sources == {OFFICIAL_MODULE_PACKAGE_CATALOG_URL}:
|
||||
return validate_official_module_package_catalog()
|
||||
return configured
|
||||
|
||||
|
||||
def _catalog_validation_exception_issues(exc: Exception, *, catalog_items: bool) -> tuple[ModuleInstallerIssue, ...]:
|
||||
severity: IssueSeverity = "blocker" if catalog_items else "warning"
|
||||
return (ModuleInstallerIssue(
|
||||
@@ -1717,6 +2063,97 @@ def _catalog_warning_issues(result: Mapping[str, object]) -> tuple[ModuleInstall
|
||||
return tuple(ModuleInstallerIssue("warning", "catalog_warning", str(warning)) for warning in warnings)
|
||||
|
||||
|
||||
def _catalog_plan_binding_issues(
|
||||
items: tuple[ModuleInstallPlanItem, ...],
|
||||
validation: Mapping[str, object],
|
||||
) -> tuple[ModuleInstallerIssue, ...]:
|
||||
"""Require every trusted plan row to match its signed catalog entry exactly."""
|
||||
|
||||
modules = _catalog_modules_by_id(validation)
|
||||
issues: list[ModuleInstallerIssue] = []
|
||||
for item in items:
|
||||
entry = modules.get(item.module_id)
|
||||
if entry is None or entry.get("action") not in PACKAGE_TARGET_ACTIONS:
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"blocker",
|
||||
"catalog_plan_entry_missing",
|
||||
f"The validated catalog no longer contains an install or update entry for {item.module_id!r}.",
|
||||
item.module_id,
|
||||
))
|
||||
continue
|
||||
mismatches = _catalog_plan_entry_mismatches(item, entry, validation)
|
||||
if mismatches:
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"blocker",
|
||||
"catalog_plan_binding_mismatch",
|
||||
(
|
||||
"The saved package plan differs from its validated signed catalog entry "
|
||||
f"for: {', '.join(mismatches)}. Remove and add the catalog item again."
|
||||
),
|
||||
item.module_id,
|
||||
))
|
||||
return tuple(issues)
|
||||
|
||||
|
||||
def _catalog_plan_entry_mismatches(
|
||||
item: ModuleInstallPlanItem,
|
||||
entry: Mapping[str, object],
|
||||
validation: Mapping[str, object],
|
||||
) -> tuple[str, ...]:
|
||||
mismatches: list[str] = []
|
||||
for attribute in ("python_package", "python_ref", "webui_package", "webui_ref"):
|
||||
if getattr(item, attribute) != _catalog_optional_string(entry, attribute):
|
||||
mismatches.append(attribute)
|
||||
|
||||
if _catalog_integrity_identity(item.artifact_integrity) != _catalog_integrity_identity(
|
||||
entry.get("artifact_integrity")
|
||||
):
|
||||
mismatches.append("artifact_integrity")
|
||||
|
||||
catalog = item.catalog if isinstance(item.catalog, Mapping) else {}
|
||||
expected_snapshot = {
|
||||
"source": validation.get("source") or validation.get("path"),
|
||||
"channel": validation.get("channel"),
|
||||
"sequence": validation.get("sequence"),
|
||||
"signed": bool(validation.get("signed")),
|
||||
"trusted": bool(validation.get("trusted")),
|
||||
"key_id": validation.get("key_id"),
|
||||
}
|
||||
for attribute, expected in expected_snapshot.items():
|
||||
actual = catalog.get(attribute)
|
||||
if actual != expected:
|
||||
mismatches.append(f"catalog.{attribute}")
|
||||
return tuple(mismatches)
|
||||
|
||||
|
||||
def _catalog_integrity_identity(value: object) -> dict[str, dict[str, object]]:
|
||||
if not isinstance(value, Mapping):
|
||||
return {}
|
||||
identity: dict[str, dict[str, object]] = {}
|
||||
for kind in ("python", "webui"):
|
||||
raw = value.get(kind)
|
||||
if not isinstance(raw, Mapping):
|
||||
continue
|
||||
identity[kind] = {
|
||||
field: raw.get(field)
|
||||
for field in (
|
||||
"ref",
|
||||
"url",
|
||||
"filename",
|
||||
"sha256",
|
||||
"size",
|
||||
"integrity",
|
||||
"sbom_url",
|
||||
"provenance_url",
|
||||
"registry_identity",
|
||||
"git_ref",
|
||||
"source_commit",
|
||||
)
|
||||
if raw.get(field) is not None
|
||||
}
|
||||
return identity
|
||||
|
||||
|
||||
def _module_install_target_plan(
|
||||
plan: ModuleInstallPlan,
|
||||
available: Mapping[str, ModuleManifest],
|
||||
@@ -2549,12 +2986,15 @@ def _topological_cycle_ids(incoming: Mapping[str, set[str]]) -> tuple[str, ...]:
|
||||
def _catalog_modules_for_target_plan(
|
||||
planned_items: tuple[ModuleInstallPlanItem, ...],
|
||||
) -> dict[str, Mapping[str, object]]:
|
||||
if not any(item.source == "catalog" and item.action in PACKAGE_TARGET_ACTIONS for item in planned_items):
|
||||
catalog_items = tuple(
|
||||
item
|
||||
for item in planned_items
|
||||
if item.source == "catalog" and item.action in PACKAGE_TARGET_ACTIONS
|
||||
)
|
||||
if not catalog_items:
|
||||
return {}
|
||||
try:
|
||||
from govoplan_core.core.module_package_catalog import validate_module_package_catalog
|
||||
|
||||
result = validate_module_package_catalog()
|
||||
result = _validate_catalog_for_plan(catalog_items)
|
||||
except Exception:
|
||||
return {}
|
||||
if result.get("valid") is not True:
|
||||
@@ -3264,6 +3704,31 @@ def _rollback_after_supervisor_failure(
|
||||
session.commit()
|
||||
supervisor["rollback"] = rollback.as_dict()
|
||||
|
||||
if rollback.return_code == 0 and result.recovery is not None:
|
||||
try:
|
||||
result.recovery.recovered(
|
||||
session,
|
||||
evidence={
|
||||
"rollback_sha256": canonical_sha256(rollback.as_dict()),
|
||||
"desired_graph_restored": True,
|
||||
},
|
||||
summary="Supervisor rollback restored package and desired module state",
|
||||
)
|
||||
supervisor["recovery_operation_id"] = result.recovery.operation_id
|
||||
supervisor["recovery_status"] = "recovered"
|
||||
_update_run_record(
|
||||
result.record_path,
|
||||
{
|
||||
"recovery": _module_lifecycle_recovery_record(
|
||||
result.recovery,
|
||||
status="recovered",
|
||||
)
|
||||
},
|
||||
)
|
||||
except Exception as recovery_exc:
|
||||
supervisor["recovery_status"] = "reconciliation-failed"
|
||||
supervisor["recovery_error"] = type(recovery_exc).__name__
|
||||
|
||||
rollback_restart = _run_restart_commands(restart_commands)
|
||||
if rollback_restart:
|
||||
supervisor["rollback_restart_commands"] = rollback_restart
|
||||
@@ -3284,6 +3749,7 @@ def _rollback_after_supervisor_failure(
|
||||
return_code=1,
|
||||
error=reason,
|
||||
rollback=rollback.as_dict(),
|
||||
recovery=result.recovery,
|
||||
)
|
||||
|
||||
|
||||
@@ -3478,6 +3944,156 @@ def _configured_require_artifact_integrity() -> bool:
|
||||
return os.getenv("GOVOPLAN_MODULE_INSTALLER_REQUIRE_ARTIFACT_INTEGRITY", "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def acquire_catalog_package_artifacts(
|
||||
plan: ModuleInstallPlan,
|
||||
*,
|
||||
runtime_dir: Path,
|
||||
) -> ModuleInstallPlan:
|
||||
"""Materialize trusted catalog archives before package mutation."""
|
||||
|
||||
items: list[ModuleInstallPlanItem] = []
|
||||
for item in plan.items:
|
||||
if item.status != "planned" or item.action not in PACKAGE_TARGET_ACTIONS:
|
||||
items.append(item)
|
||||
continue
|
||||
raw_integrity = item.artifact_integrity
|
||||
if not isinstance(raw_integrity, Mapping):
|
||||
items.append(item)
|
||||
continue
|
||||
integrity: dict[str, object] = dict(raw_integrity)
|
||||
changed = False
|
||||
for kind in ("python", "webui"):
|
||||
metadata = _artifact_metadata(integrity, kind)
|
||||
if metadata is None or _artifact_path(metadata) is not None:
|
||||
continue
|
||||
if not _catalog_artifact_acquisition_ready(item, metadata):
|
||||
continue
|
||||
updated = dict(metadata)
|
||||
updated["artifact_path"] = str(
|
||||
_acquire_package_artifact(
|
||||
metadata,
|
||||
runtime_dir=runtime_dir,
|
||||
module_id=item.module_id,
|
||||
kind=kind,
|
||||
)
|
||||
)
|
||||
integrity[kind] = updated
|
||||
changed = True
|
||||
items.append(replace(item, artifact_integrity=integrity) if changed else item)
|
||||
return replace(plan, items=tuple(items))
|
||||
|
||||
|
||||
def _catalog_artifact_acquisition_ready(
|
||||
item: ModuleInstallPlanItem,
|
||||
metadata: Mapping[str, object],
|
||||
) -> bool:
|
||||
catalog = item.catalog
|
||||
if (
|
||||
item.source != "catalog"
|
||||
or not isinstance(catalog, Mapping)
|
||||
or catalog.get("signed") is not True
|
||||
or catalog.get("trusted") is not True
|
||||
):
|
||||
return False
|
||||
url = _artifact_text(metadata, "url")
|
||||
filename = _artifact_text(metadata, "filename")
|
||||
sha256 = _artifact_text(metadata, "sha256")
|
||||
size = metadata.get("size")
|
||||
return bool(
|
||||
url
|
||||
and url.startswith("https://")
|
||||
and filename
|
||||
and Path(filename).name == filename
|
||||
and sha256
|
||||
and re.fullmatch(r"[0-9a-f]{64}", sha256.lower())
|
||||
and isinstance(size, int)
|
||||
and not isinstance(size, bool)
|
||||
and 0 < size <= MAX_PACKAGE_ARTIFACT_BYTES
|
||||
)
|
||||
|
||||
|
||||
def _acquire_package_artifact(
|
||||
metadata: Mapping[str, object],
|
||||
*,
|
||||
runtime_dir: Path,
|
||||
module_id: str,
|
||||
kind: str,
|
||||
) -> Path:
|
||||
url = validate_http_url(_artifact_text(metadata, "url") or "", label=f"{kind.capitalize()} package URL")
|
||||
if not url.startswith("https://"):
|
||||
raise ModuleInstallerError(f"{kind.capitalize()} package URL must use HTTPS.")
|
||||
filename = _artifact_text(metadata, "filename") or ""
|
||||
expected_sha256 = (_artifact_text(metadata, "sha256") or "").lower()
|
||||
expected_size = metadata.get("size")
|
||||
if (
|
||||
not filename
|
||||
or Path(filename).name != filename
|
||||
or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._+!-]{0,255}", filename) is None
|
||||
or re.fullmatch(r"[0-9a-f]{64}", expected_sha256) is None
|
||||
or not isinstance(expected_size, int)
|
||||
or isinstance(expected_size, bool)
|
||||
or not 0 < expected_size <= MAX_PACKAGE_ARTIFACT_BYTES
|
||||
):
|
||||
raise ModuleInstallerError(f"Catalog artifact metadata is incomplete for {module_id}/{kind}.")
|
||||
|
||||
cache_root = runtime_dir / "artifacts"
|
||||
_ensure_private_artifact_directory(cache_root)
|
||||
digest_root = cache_root / expected_sha256
|
||||
_ensure_private_artifact_directory(digest_root)
|
||||
target = digest_root / filename
|
||||
if target.exists() or target.is_symlink():
|
||||
if target.is_symlink() or not target.is_file():
|
||||
raise ModuleInstallerError(f"Cached package artifact is not a regular file: {target}")
|
||||
if target.stat().st_size != expected_size or _sha256_file(target) != expected_sha256:
|
||||
raise ModuleInstallerError(f"Cached package artifact does not match its catalog identity: {target}")
|
||||
return target
|
||||
|
||||
try:
|
||||
response = fetch_http(
|
||||
url,
|
||||
timeout=float(os.getenv("GOVOPLAN_MODULE_INSTALLER_DOWNLOAD_TIMEOUT_SECONDS", "120")),
|
||||
label=f"{module_id} {kind} package URL",
|
||||
max_bytes=min(expected_size + 1, MAX_PACKAGE_ARTIFACT_BYTES),
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise ModuleInstallerError(f"Could not download {module_id} {kind} package: {exc}") from exc
|
||||
if response.status < 200 or response.status >= 300:
|
||||
raise ModuleInstallerError(f"Could not download {module_id} {kind} package: HTTP {response.status}.")
|
||||
if len(response.body) != expected_size or hashlib.sha256(response.body).hexdigest() != expected_sha256:
|
||||
raise ModuleInstallerError(f"Downloaded {module_id} {kind} package does not match its signed catalog identity.")
|
||||
|
||||
temporary_path: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="wb",
|
||||
prefix=f".{filename}.",
|
||||
suffix=".tmp",
|
||||
dir=digest_root,
|
||||
delete=False,
|
||||
) as handle:
|
||||
temporary_path = Path(handle.name)
|
||||
handle.write(response.body)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
temporary_path.chmod(0o600)
|
||||
os.replace(temporary_path, target)
|
||||
target.chmod(0o600)
|
||||
except OSError as exc:
|
||||
if temporary_path is not None:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
raise ModuleInstallerError(f"Could not cache {module_id} {kind} package.") from exc
|
||||
return target
|
||||
|
||||
|
||||
def _ensure_private_artifact_directory(path: Path) -> None:
|
||||
if path.is_symlink():
|
||||
raise ModuleInstallerError(f"Installer artifact cache must not be a symlink: {path}")
|
||||
path.mkdir(parents=True, mode=0o700, exist_ok=True)
|
||||
path.chmod(0o700)
|
||||
if not path.is_dir() or stat.S_IMODE(path.stat().st_mode) != 0o700:
|
||||
raise ModuleInstallerError(f"Installer artifact cache is not private: {path}")
|
||||
|
||||
|
||||
def _verify_artifact_integrity(
|
||||
planned_items: tuple[ModuleInstallPlanItem, ...],
|
||||
*,
|
||||
@@ -3545,7 +4161,17 @@ def _verify_artifact_metadata(
|
||||
}
|
||||
if package_name:
|
||||
record["package"] = package_name
|
||||
for key in ("sha256", "sbom_url", "provenance_url", "registry_identity", "git_ref"):
|
||||
for key in (
|
||||
"sha256",
|
||||
"url",
|
||||
"filename",
|
||||
"integrity",
|
||||
"sbom_url",
|
||||
"provenance_url",
|
||||
"registry_identity",
|
||||
"git_ref",
|
||||
"source_commit",
|
||||
):
|
||||
value = _artifact_text(metadata, key)
|
||||
if value:
|
||||
record[key] = value
|
||||
@@ -3566,6 +4192,15 @@ def _verify_artifact_metadata(
|
||||
item.module_id,
|
||||
))
|
||||
return record, tuple(issues)
|
||||
if artifact_path is None and _catalog_artifact_acquisition_ready(item, metadata):
|
||||
record["acquisition_pending"] = True
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"info",
|
||||
"artifact_acquisition_pending",
|
||||
f"{kind.capitalize()} artifact will be downloaded and verified by the installer daemon before package mutation.",
|
||||
item.module_id,
|
||||
))
|
||||
return record, tuple(issues)
|
||||
if artifact_path is None:
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"blocker" if require_verified else "warning",
|
||||
@@ -3698,10 +4333,13 @@ def _snapshot_sqlite_database(run_dir: Path, database_url: str | None) -> dict[s
|
||||
raise ModuleInstallerError(
|
||||
f"SQLite backup failed its restore-readiness integrity check: {integrity}"
|
||||
)
|
||||
artifact_sha256 = _sha256_file(backup_path)
|
||||
return {
|
||||
"type": "sqlite",
|
||||
"source": str(db_path),
|
||||
"path": backup_path.name,
|
||||
"artifact_sha256": artifact_sha256,
|
||||
"size_bytes": backup_path.stat().st_size,
|
||||
"restore_check": {
|
||||
"type": "sqlite_integrity_check",
|
||||
"result": integrity,
|
||||
@@ -3744,6 +4382,12 @@ def _snapshot_external_database(
|
||||
payload["database_url_secret"] = database_url_secret
|
||||
if result.returncode != 0:
|
||||
raise ModuleInstallerError(f"Database backup command failed ({result.returncode}): {_redact_installer_text(backup_command, redactions=redactions)}")
|
||||
if not backup_path.is_file() or backup_path.stat().st_size <= 0:
|
||||
raise ModuleInstallerError(
|
||||
"Database backup command did not create a non-empty backup artifact."
|
||||
)
|
||||
payload["artifact_sha256"] = _sha256_file(backup_path)
|
||||
payload["size_bytes"] = backup_path.stat().st_size
|
||||
if restore_check_command:
|
||||
restore_check = _run_database_hook(
|
||||
restore_check_command,
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Mapping, Sequence
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_core.core.recovery import (
|
||||
RecoveryGuaranteeError,
|
||||
RecoveryMode,
|
||||
RecoveryOperation,
|
||||
RecoveryPlan,
|
||||
RecoveryStatus,
|
||||
)
|
||||
from govoplan_core.core.recovery_runtime import (
|
||||
DurableRecoveryOperation,
|
||||
RecoveryOperationBusy,
|
||||
RecoveryOperationStateConflict,
|
||||
begin_durable_recovery_operation,
|
||||
claim_durable_recovery_operation,
|
||||
)
|
||||
from govoplan_core.core.runtime_coordination import process_runtime_identity
|
||||
|
||||
|
||||
class ModuleLifecycleRecoveryError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModuleLifecycleRecoveryDeclaration:
|
||||
operation_type: str
|
||||
mode: RecoveryMode
|
||||
resources: tuple[str, ...]
|
||||
verification: tuple[str, ...]
|
||||
|
||||
|
||||
MODULE_LIFECYCLE_RECOVERY_OPERATIONS = (
|
||||
ModuleLifecycleRecoveryDeclaration(
|
||||
operation_type="module-lifecycle.pre-migration",
|
||||
mode=RecoveryMode.COMPENSATION,
|
||||
resources=("postgresql", "package-environment", "webui-bundle", "filesystem"),
|
||||
verification=(
|
||||
"verify the canonical install plan and immutable package references",
|
||||
"verify the package and WebUI snapshots before mutation",
|
||||
"verify the installed manifests and desired module graph",
|
||||
),
|
||||
),
|
||||
ModuleLifecycleRecoveryDeclaration(
|
||||
operation_type="module-lifecycle.post-migration",
|
||||
mode=RecoveryMode.FORWARD_RECOVERY,
|
||||
resources=(
|
||||
"postgresql",
|
||||
"package-environment",
|
||||
"webui-bundle",
|
||||
"runtime-nodes",
|
||||
),
|
||||
verification=(
|
||||
"verify the backup reference and migration execution evidence",
|
||||
"verify migration heads and installed module manifests",
|
||||
"verify the desired graph and runtime health before completion",
|
||||
),
|
||||
),
|
||||
ModuleLifecycleRecoveryDeclaration(
|
||||
operation_type="module-retirement.destroy-data",
|
||||
mode=RecoveryMode.SNAPSHOT_RESTORE,
|
||||
resources=("postgresql", "object-storage", "package-environment"),
|
||||
verification=(
|
||||
"verify the pinned backup artifact and restore-readiness evidence",
|
||||
"verify the retirement provider result and remaining migration state",
|
||||
"verify the installed manifests and desired module graph",
|
||||
),
|
||||
),
|
||||
ModuleLifecycleRecoveryDeclaration(
|
||||
operation_type="module-runtime.apply-graph",
|
||||
mode=RecoveryMode.COMPENSATION,
|
||||
resources=("postgresql", "runtime-nodes", "module-registry"),
|
||||
verification=(
|
||||
"verify the requested graph against available module contracts",
|
||||
"verify activation and deactivation hooks completed",
|
||||
"verify the active graph and workflow contribution reconciliation",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_DECLARATIONS = {
|
||||
item.operation_type: item for item in MODULE_LIFECYCLE_RECOVERY_OPERATIONS
|
||||
}
|
||||
|
||||
|
||||
def canonical_sha256(value: object) -> str:
|
||||
encoded = json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=True,
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def lifecycle_session_factory(session: Session) -> sessionmaker[Session]:
|
||||
bind = session.get_bind()
|
||||
if bind is None:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"Module lifecycle recovery requires a database bind"
|
||||
)
|
||||
return sessionmaker(bind=bind, expire_on_commit=False)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ModuleLifecycleRecovery:
|
||||
operation: DurableRecoveryOperation | None
|
||||
operation_id: str
|
||||
operation_type: str
|
||||
mode: RecoveryMode
|
||||
plan_sha256: str
|
||||
replayed: bool
|
||||
effect_started: bool = False
|
||||
|
||||
def checkpoint(
|
||||
self,
|
||||
*,
|
||||
kind: str,
|
||||
summary: str,
|
||||
evidence: Mapping[str, object],
|
||||
effect_started: bool = False,
|
||||
) -> None:
|
||||
if self.operation is None:
|
||||
return
|
||||
self.effect_started = self.effect_started or effect_started
|
||||
self.operation.checkpoint(
|
||||
kind=kind,
|
||||
summary=summary,
|
||||
evidence={
|
||||
**dict(evidence),
|
||||
"effect_started": self.effect_started,
|
||||
"plan_sha256": self.plan_sha256,
|
||||
},
|
||||
)
|
||||
|
||||
def succeed(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
evidence: Mapping[str, object],
|
||||
commit_projection: bool,
|
||||
) -> None:
|
||||
if self.operation is None:
|
||||
return
|
||||
terminal = {
|
||||
"verified": True,
|
||||
"checks": {
|
||||
**dict(evidence),
|
||||
"plan_sha256": self.plan_sha256,
|
||||
"effect_started": self.effect_started,
|
||||
},
|
||||
}
|
||||
if commit_projection:
|
||||
self.operation.commit_verified_success(session, evidence=terminal)
|
||||
else:
|
||||
self.operation.succeed(evidence=terminal)
|
||||
|
||||
def unresolved(
|
||||
self,
|
||||
*,
|
||||
summary: str,
|
||||
evidence: Mapping[str, object],
|
||||
outcome_unknown: bool,
|
||||
) -> None:
|
||||
if self.operation is None:
|
||||
return
|
||||
if not self.effect_started:
|
||||
self.operation.fail(
|
||||
summary=summary,
|
||||
evidence={
|
||||
"verified": True,
|
||||
"checks": {
|
||||
**dict(evidence),
|
||||
"effect_started": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
return
|
||||
self.operation.unresolved(
|
||||
status=(
|
||||
RecoveryStatus.OUTCOME_UNKNOWN
|
||||
if outcome_unknown
|
||||
else RecoveryStatus.RECOVERY_REQUIRED
|
||||
),
|
||||
summary=summary,
|
||||
evidence={
|
||||
**dict(evidence),
|
||||
"effect_started": True,
|
||||
"plan_sha256": self.plan_sha256,
|
||||
},
|
||||
failure_summary=(
|
||||
"Inspect the installer run record and affected state services "
|
||||
"before retrying or restoring"
|
||||
),
|
||||
)
|
||||
|
||||
def recovered(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
evidence: Mapping[str, object],
|
||||
summary: str,
|
||||
) -> None:
|
||||
state = session.get(RecoveryOperation, self.operation_id)
|
||||
if state is None:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"Module lifecycle recovery operation is unavailable"
|
||||
)
|
||||
if state.status == RecoveryStatus.RECOVERED.value:
|
||||
return
|
||||
try:
|
||||
handle = claim_durable_recovery_operation(
|
||||
lifecycle_session_factory(session),
|
||||
identity=process_runtime_identity(),
|
||||
operation_id=self.operation_id,
|
||||
lease_ttl_seconds=900,
|
||||
)
|
||||
except RecoveryOperationStateConflict as exc:
|
||||
if exc.status == RecoveryStatus.RECOVERED.value:
|
||||
return
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
f"Module lifecycle recovery is already {exc.status}"
|
||||
) from exc
|
||||
except (RecoveryOperationBusy, RecoveryGuaranteeError, RuntimeError) as exc:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"Module lifecycle recovery authority is unavailable"
|
||||
) from exc
|
||||
session.expire_all()
|
||||
state = session.get(RecoveryOperation, self.operation_id)
|
||||
if state is None:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"Module lifecycle recovery operation is unavailable"
|
||||
)
|
||||
recovery_evidence = {
|
||||
"verified": True,
|
||||
"checks": {
|
||||
**dict(evidence),
|
||||
"plan_sha256": self.plan_sha256,
|
||||
},
|
||||
}
|
||||
if state.status == RecoveryStatus.OUTCOME_UNKNOWN.value:
|
||||
handle.resolve_unknown(
|
||||
effect_occurred=False,
|
||||
evidence=recovery_evidence,
|
||||
summary=summary,
|
||||
)
|
||||
else:
|
||||
handle.compensate(
|
||||
failure_summary=summary,
|
||||
failure_evidence={
|
||||
"effect_started": self.effect_started,
|
||||
"plan_sha256": self.plan_sha256,
|
||||
},
|
||||
recovery_evidence=recovery_evidence,
|
||||
)
|
||||
|
||||
|
||||
def begin_module_installer_recovery(
|
||||
session: Session,
|
||||
*,
|
||||
run_id: str,
|
||||
plan: Sequence[Mapping[str, object]],
|
||||
command_count: int,
|
||||
migrate_database: bool,
|
||||
destructive_retirement: bool,
|
||||
snapshot_sha256: str | None,
|
||||
backup_reference: str | None,
|
||||
request_context_sha256: str,
|
||||
) -> ModuleLifecycleRecovery:
|
||||
operation_type = (
|
||||
"module-retirement.destroy-data"
|
||||
if destructive_retirement
|
||||
else "module-lifecycle.post-migration"
|
||||
if migrate_database
|
||||
else "module-lifecycle.pre-migration"
|
||||
)
|
||||
declaration = _DECLARATIONS[operation_type]
|
||||
plan_sha256 = canonical_sha256([dict(item) for item in plan])
|
||||
recovery_plan = RecoveryPlan(
|
||||
mode=declaration.mode,
|
||||
preconditions=(
|
||||
"maintenance mode and installer preflight are current",
|
||||
"package references and the requested module graph are pinned",
|
||||
"the deployment-wide module lifecycle fence is owned",
|
||||
),
|
||||
compensation_steps=(
|
||||
"restore the Python and WebUI package snapshots",
|
||||
"restore the prior desired module graph",
|
||||
"verify installed manifests and runtime health",
|
||||
)
|
||||
if declaration.mode == RecoveryMode.COMPENSATION
|
||||
else (),
|
||||
forward_recovery_steps=(
|
||||
"inspect migration task and command evidence",
|
||||
"complete or repair migrations under the same deployment fence",
|
||||
"verify migration heads, manifests, desired graph, and runtime health",
|
||||
)
|
||||
if declaration.mode == RecoveryMode.FORWARD_RECOVERY
|
||||
else (),
|
||||
verification_steps=declaration.verification,
|
||||
backup_reference=(
|
||||
backup_reference
|
||||
if declaration.mode == RecoveryMode.SNAPSHOT_RESTORE
|
||||
else None
|
||||
),
|
||||
)
|
||||
if declaration.mode == RecoveryMode.SNAPSHOT_RESTORE and not backup_reference:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"Destructive module retirement requires verified backup evidence"
|
||||
)
|
||||
session.commit()
|
||||
try:
|
||||
started = begin_durable_recovery_operation(
|
||||
lifecycle_session_factory(session),
|
||||
identity=process_runtime_identity(),
|
||||
module_id="core",
|
||||
operation_type=operation_type,
|
||||
idempotency_key=f"module-installer:{run_id}",
|
||||
request={
|
||||
"run_id": run_id,
|
||||
"plan_sha256": plan_sha256,
|
||||
"command_count": command_count,
|
||||
"migrate_database": migrate_database,
|
||||
"destructive_retirement": destructive_retirement,
|
||||
"snapshot_expected": True,
|
||||
"request_context_sha256": request_context_sha256,
|
||||
},
|
||||
recovery_plan=recovery_plan,
|
||||
precondition_evidence={
|
||||
"plan_sha256": plan_sha256,
|
||||
"snapshot_sha256": snapshot_sha256 or "pending",
|
||||
"request_context_sha256": request_context_sha256,
|
||||
"command_count": command_count,
|
||||
"backup_reference_present": bool(backup_reference),
|
||||
},
|
||||
lease_resource_key="core:module-lifecycle:deployment",
|
||||
lease_ttl_seconds=900,
|
||||
resource_type="module_installer_run",
|
||||
resource_id=run_id,
|
||||
metadata={
|
||||
"resources": list(declaration.resources),
|
||||
"migrate_database": migrate_database,
|
||||
"destructive_retirement": destructive_retirement,
|
||||
},
|
||||
block_unresolved_resource=True,
|
||||
)
|
||||
except RecoveryOperationBusy as exc:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"Another runtime owns the deployment module lifecycle fence"
|
||||
) from exc
|
||||
except RecoveryOperationStateConflict as exc:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
f"Module installer recovery is already {exc.status}"
|
||||
) from exc
|
||||
except (RecoveryGuaranteeError, RuntimeError, SQLAlchemyError, ValueError) as exc:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"The recovery ledger is unavailable; module mutation did not start"
|
||||
) from exc
|
||||
return ModuleLifecycleRecovery(
|
||||
operation=started.operation,
|
||||
operation_id=started.operation_id,
|
||||
operation_type=operation_type,
|
||||
mode=declaration.mode,
|
||||
plan_sha256=plan_sha256,
|
||||
replayed=started.replayed,
|
||||
)
|
||||
|
||||
|
||||
def begin_runtime_graph_recovery(
|
||||
session: Session,
|
||||
*,
|
||||
previous_modules: Sequence[str],
|
||||
requested_modules: Sequence[str],
|
||||
migrate: bool,
|
||||
) -> ModuleLifecycleRecovery:
|
||||
declaration = _DECLARATIONS["module-runtime.apply-graph"]
|
||||
plan = {
|
||||
"previous_modules": sorted(set(previous_modules)),
|
||||
"requested_modules": sorted(set(requested_modules)),
|
||||
"migrate": migrate,
|
||||
}
|
||||
plan_sha256 = canonical_sha256(plan)
|
||||
session.commit()
|
||||
try:
|
||||
started = begin_durable_recovery_operation(
|
||||
lifecycle_session_factory(session),
|
||||
identity=process_runtime_identity(),
|
||||
module_id="core",
|
||||
operation_type=declaration.operation_type,
|
||||
idempotency_key=f"module-runtime:{uuid4()}",
|
||||
request={**plan, "plan_sha256": plan_sha256},
|
||||
recovery_plan=RecoveryPlan(
|
||||
mode=declaration.mode,
|
||||
preconditions=(
|
||||
"the requested graph passed module contract validation",
|
||||
"the deployment-wide module lifecycle fence is owned",
|
||||
),
|
||||
compensation_steps=(
|
||||
"restore the previous in-process active registry",
|
||||
"reconfigure capability contexts from the previous graph",
|
||||
),
|
||||
verification_steps=declaration.verification,
|
||||
),
|
||||
precondition_evidence={
|
||||
"plan_sha256": plan_sha256,
|
||||
"previous_graph_sha256": canonical_sha256(
|
||||
sorted(set(previous_modules))
|
||||
),
|
||||
"requested_graph_sha256": canonical_sha256(
|
||||
sorted(set(requested_modules))
|
||||
),
|
||||
},
|
||||
lease_resource_key="core:module-lifecycle:deployment",
|
||||
lease_ttl_seconds=300,
|
||||
resource_type="module_runtime_graph",
|
||||
resource_id=plan_sha256,
|
||||
metadata={"resources": list(declaration.resources)},
|
||||
block_unresolved_resource=True,
|
||||
)
|
||||
except (RecoveryOperationBusy, RecoveryOperationStateConflict) as exc:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"Another lifecycle mutation is active or unresolved"
|
||||
) from exc
|
||||
except (RecoveryGuaranteeError, RuntimeError, SQLAlchemyError, ValueError) as exc:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"The recovery ledger is unavailable; the active graph was unchanged"
|
||||
) from exc
|
||||
return ModuleLifecycleRecovery(
|
||||
operation=started.operation,
|
||||
operation_id=started.operation_id,
|
||||
operation_type=declaration.operation_type,
|
||||
mode=declaration.mode,
|
||||
plan_sha256=plan_sha256,
|
||||
replayed=started.replayed,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MODULE_LIFECYCLE_RECOVERY_OPERATIONS",
|
||||
"ModuleLifecycleRecovery",
|
||||
"ModuleLifecycleRecoveryDeclaration",
|
||||
"ModuleLifecycleRecoveryError",
|
||||
"begin_module_installer_recovery",
|
||||
"begin_runtime_graph_recovery",
|
||||
"canonical_sha256",
|
||||
"lifecycle_session_factory",
|
||||
]
|
||||
@@ -6,6 +6,7 @@ from collections import defaultdict
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from importlib.resources import files
|
||||
from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
@@ -17,6 +18,10 @@ from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
||||
|
||||
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
||||
from govoplan_core.core.information_governance import (
|
||||
information_governance_from_mapping,
|
||||
information_governance_maturity_issues,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import (
|
||||
external_provider_from_mapping,
|
||||
module_architecture_from_mapping,
|
||||
@@ -25,6 +30,11 @@ from govoplan_core.core.provider_governance import (
|
||||
from govoplan_core.security.http_fetch import fetch_http_text, is_http_url
|
||||
|
||||
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
|
||||
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
_ARTIFACT_FILENAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+!-]{0,255}$")
|
||||
_SOURCE_REPOSITORY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$")
|
||||
_SOURCE_REF_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/+!-]{0,127}$")
|
||||
_SOURCE_COMMIT_RE = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$")
|
||||
CATALOG_MIGRATION_SAFETY = ("automatic", "requires_review", "forward_only", "destructive")
|
||||
CATALOG_MIGRATION_TASK_PHASES = (
|
||||
"pre_migration_check",
|
||||
@@ -32,6 +42,8 @@ CATALOG_MIGRATION_TASK_PHASES = (
|
||||
"post_migration_backfill",
|
||||
"post_migration_verify",
|
||||
)
|
||||
OFFICIAL_MODULE_PACKAGE_CATALOG_URL = "https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json"
|
||||
OFFICIAL_MODULE_PACKAGE_CATALOG_CHANNEL = "stable"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -97,6 +109,18 @@ def validate_module_package_catalog(
|
||||
return _valid_catalog_result(catalog_source, state)
|
||||
|
||||
|
||||
def validate_official_module_package_catalog() -> dict[str, object]:
|
||||
"""Read the public GovOPlaN directory against Core's pinned trust anchor."""
|
||||
|
||||
keyring = files("govoplan_core").joinpath("resources/catalog-keyring.json").read_text(encoding="utf-8")
|
||||
return validate_module_package_catalog(
|
||||
OFFICIAL_MODULE_PACKAGE_CATALOG_URL,
|
||||
require_trusted=True,
|
||||
approved_channels=(OFFICIAL_MODULE_PACKAGE_CATALOG_CHANNEL,),
|
||||
trusted_keys=_parse_trusted_keys(keyring),
|
||||
)
|
||||
|
||||
|
||||
def _catalog_validation_state(
|
||||
source: Path | str | None,
|
||||
*,
|
||||
@@ -319,7 +343,10 @@ def _configured_require_signature() -> bool:
|
||||
|
||||
|
||||
def _configured_approved_channels() -> tuple[str, ...]:
|
||||
value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS", "").strip()
|
||||
value = (
|
||||
os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS", "").strip()
|
||||
or os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL", "").strip()
|
||||
)
|
||||
if not value:
|
||||
return ()
|
||||
return tuple(item.strip() for item in value.split(",") if item.strip())
|
||||
@@ -628,8 +655,29 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
|
||||
"requires_interfaces": _normalize_catalog_interface_requirements(value.get("requires_interfaces"), module_id=module_id),
|
||||
"notes": _optional_str(value, "notes"),
|
||||
"tags": _string_list(value.get("tags")),
|
||||
"availability": _catalog_availability(value, module_id=module_id),
|
||||
"availability_reason": _optional_str(value, "availability_reason"),
|
||||
"configuration_requirements": _string_list(value.get("configuration_requirements")),
|
||||
"permissions": _normalize_catalog_permissions(
|
||||
value.get("permissions"),
|
||||
module_id=module_id,
|
||||
),
|
||||
}
|
||||
if item["availability"] == "withdrawn" and not item["availability_reason"]:
|
||||
raise ValueError(
|
||||
f"Withdrawn module package catalog entry {module_id!r} requires availability_reason."
|
||||
)
|
||||
release_notes_url = _optional_str(value, "release_notes_url")
|
||||
if release_notes_url is not None:
|
||||
item["release_notes_url"] = _catalog_https_url(
|
||||
release_notes_url,
|
||||
label=f"Module package catalog release_notes_url for {module_id!r}",
|
||||
)
|
||||
source = _normalize_catalog_source(value.get("source"), module_id=module_id)
|
||||
if source:
|
||||
item["source"] = source
|
||||
raw_architecture = value.get("architecture")
|
||||
architecture_maturity: str | None = None
|
||||
if raw_architecture is not None:
|
||||
if not isinstance(raw_architecture, Mapping):
|
||||
raise ValueError(
|
||||
@@ -647,6 +695,27 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
|
||||
+ "; ".join(issues)
|
||||
)
|
||||
item["architecture"] = architecture.to_dict()
|
||||
architecture_maturity = architecture.maturity
|
||||
raw_information_governance = value.get("information_governance")
|
||||
if raw_information_governance is not None:
|
||||
if not isinstance(raw_information_governance, Mapping):
|
||||
raise ValueError(
|
||||
"Module package catalog information_governance for "
|
||||
f"{module_id!r} must be an object."
|
||||
)
|
||||
information_governance = information_governance_from_mapping(
|
||||
raw_information_governance
|
||||
)
|
||||
governance_issues = information_governance_maturity_issues(
|
||||
information_governance,
|
||||
maturity=architecture_maturity,
|
||||
)
|
||||
if governance_issues:
|
||||
raise ValueError(
|
||||
"Module package catalog information_governance for "
|
||||
f"{module_id!r} is invalid: " + "; ".join(governance_issues)
|
||||
)
|
||||
item["information_governance"] = information_governance.to_dict()
|
||||
raw_providers = value.get("external_providers")
|
||||
if raw_providers is not None:
|
||||
if not isinstance(raw_providers, list):
|
||||
@@ -710,6 +779,170 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
|
||||
return item
|
||||
|
||||
|
||||
def _catalog_availability(value: Mapping[str, object], *, module_id: str) -> str:
|
||||
availability = str(value.get("availability") or "available").strip().lower()
|
||||
if availability not in {"available", "withdrawn"}:
|
||||
raise ValueError(
|
||||
f"Unsupported catalog availability for {module_id!r}: {availability!r}."
|
||||
)
|
||||
return availability
|
||||
|
||||
|
||||
def _normalize_catalog_permissions(
|
||||
value: object,
|
||||
*,
|
||||
module_id: str,
|
||||
) -> list[dict[str, object]]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise ValueError(
|
||||
f"Module package catalog permissions for {module_id!r} must be a list."
|
||||
)
|
||||
if len(value) > 1000:
|
||||
raise ValueError(
|
||||
f"Module package catalog permissions for {module_id!r} exceed 1000 entries."
|
||||
)
|
||||
normalized: list[dict[str, object]] = []
|
||||
seen: set[str] = set()
|
||||
for raw in value:
|
||||
if not isinstance(raw, Mapping):
|
||||
raise ValueError(
|
||||
f"Module package catalog permission entries for {module_id!r} must be objects."
|
||||
)
|
||||
scope = _bounded_catalog_permission_text(
|
||||
raw,
|
||||
"scope",
|
||||
module_id=module_id,
|
||||
maximum=200,
|
||||
)
|
||||
if scope in seen:
|
||||
raise ValueError(
|
||||
f"Module package catalog entry {module_id!r} declares permission {scope!r} more than once."
|
||||
)
|
||||
seen.add(scope)
|
||||
level = _bounded_catalog_permission_text(
|
||||
raw,
|
||||
"level",
|
||||
module_id=module_id,
|
||||
maximum=20,
|
||||
)
|
||||
if level not in {"system", "tenant"}:
|
||||
raise ValueError(
|
||||
f"Module package catalog permission {module_id!r}/{scope!r} has unsupported level {level!r}."
|
||||
)
|
||||
deprecated = raw.get("deprecated", False)
|
||||
if not isinstance(deprecated, bool):
|
||||
raise ValueError(
|
||||
f"Module package catalog permission {module_id!r}/{scope!r} deprecated must be true or false."
|
||||
)
|
||||
normalized.append(
|
||||
{
|
||||
"scope": scope,
|
||||
"label": _bounded_catalog_permission_text(
|
||||
raw,
|
||||
"label",
|
||||
module_id=module_id,
|
||||
maximum=200,
|
||||
),
|
||||
"description": _bounded_catalog_permission_text(
|
||||
raw,
|
||||
"description",
|
||||
module_id=module_id,
|
||||
maximum=1000,
|
||||
),
|
||||
"category": _bounded_catalog_permission_text(
|
||||
raw,
|
||||
"category",
|
||||
module_id=module_id,
|
||||
maximum=120,
|
||||
),
|
||||
"level": level,
|
||||
"resource": _bounded_catalog_permission_text(
|
||||
raw,
|
||||
"resource",
|
||||
module_id=module_id,
|
||||
maximum=120,
|
||||
),
|
||||
"action": _bounded_catalog_permission_text(
|
||||
raw,
|
||||
"action",
|
||||
module_id=module_id,
|
||||
maximum=120,
|
||||
),
|
||||
"deprecated": deprecated,
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _bounded_catalog_permission_text(
|
||||
value: Mapping[str, object],
|
||||
key: str,
|
||||
*,
|
||||
module_id: str,
|
||||
maximum: int,
|
||||
) -> str:
|
||||
text = _required_str(value, key)
|
||||
if len(text) > maximum:
|
||||
raise ValueError(
|
||||
f"Module package catalog permission {key!r} for {module_id!r} exceeds {maximum} characters."
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def _normalize_catalog_source(
|
||||
value: object,
|
||||
*,
|
||||
module_id: str,
|
||||
) -> dict[str, object]:
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError(
|
||||
f"Module package catalog source for {module_id!r} must be an object."
|
||||
)
|
||||
repository = _required_str(value, "repository")
|
||||
tag = _required_str(value, "tag")
|
||||
commit = _required_str(value, "commit").lower()
|
||||
if (
|
||||
_SOURCE_REPOSITORY_RE.fullmatch(repository) is None
|
||||
or repository.startswith("/")
|
||||
or repository.endswith("/")
|
||||
or ".." in repository.split("/")
|
||||
):
|
||||
raise ValueError(
|
||||
f"Module package catalog source repository for {module_id!r} is invalid."
|
||||
)
|
||||
if _SOURCE_REF_RE.fullmatch(tag) is None or ".." in tag.split("/"):
|
||||
raise ValueError(
|
||||
f"Module package catalog source tag for {module_id!r} is invalid."
|
||||
)
|
||||
if _SOURCE_COMMIT_RE.fullmatch(commit) is None:
|
||||
raise ValueError(
|
||||
f"Module package catalog source commit for {module_id!r} is invalid."
|
||||
)
|
||||
source: dict[str, object] = {
|
||||
"repository": repository,
|
||||
"tag": tag,
|
||||
"commit": commit,
|
||||
}
|
||||
for field in ("repository_url", "revision_url"):
|
||||
url = _optional_str(value, field)
|
||||
if url is not None:
|
||||
source[field] = _catalog_https_url(
|
||||
url,
|
||||
label=f"Module package catalog source {field} for {module_id!r}",
|
||||
)
|
||||
return source
|
||||
|
||||
|
||||
def _catalog_https_url(value: str, *, label: str) -> str:
|
||||
if not is_http_url(value) or not value.startswith("https://"):
|
||||
raise ValueError(f"{label} must use HTTPS.")
|
||||
return value
|
||||
|
||||
|
||||
def _catalog_migration_safety(value: Any, *, module_id: str) -> str:
|
||||
if value is None:
|
||||
return "automatic"
|
||||
@@ -780,14 +1013,14 @@ def _catalog_optional_positive_int(value: dict[str, Any], key: str, *, module_id
|
||||
return integer
|
||||
|
||||
|
||||
def _required_str(value: dict[str, Any], key: str) -> str:
|
||||
def _required_str(value: Mapping[str, Any], key: str) -> str:
|
||||
item = _optional_str(value, key)
|
||||
if not item:
|
||||
raise ValueError(f"Module package catalog entry is missing {key!r}.")
|
||||
return item
|
||||
|
||||
|
||||
def _optional_str(value: dict[str, Any], key: str) -> str | None:
|
||||
def _optional_str(value: Mapping[str, Any], key: str) -> str | None:
|
||||
item = value.get(key)
|
||||
if item is None:
|
||||
return None
|
||||
@@ -943,20 +1176,40 @@ def _normalize_artifact_integrity(value: Any) -> dict[str, object]:
|
||||
continue
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"Module package catalog artifact_integrity.{key} must be an object.")
|
||||
clean = {
|
||||
clean: dict[str, object] = {
|
||||
field: text
|
||||
for field in (
|
||||
"ref",
|
||||
"path",
|
||||
"artifact_path",
|
||||
"url",
|
||||
"filename",
|
||||
"sha256",
|
||||
"integrity",
|
||||
"sbom_url",
|
||||
"provenance_url",
|
||||
"registry_identity",
|
||||
"git_ref",
|
||||
"source_commit",
|
||||
)
|
||||
if (text := _optional_str(raw, field))
|
||||
}
|
||||
url = clean.get("url")
|
||||
if isinstance(url, str) and (not is_http_url(url) or not url.startswith("https://")):
|
||||
raise ValueError(f"Module package catalog artifact_integrity.{key}.url must use HTTPS.")
|
||||
filename = clean.get("filename")
|
||||
if isinstance(filename, str) and _ARTIFACT_FILENAME_RE.fullmatch(filename) is None:
|
||||
raise ValueError(f"Module package catalog artifact_integrity.{key}.filename is invalid.")
|
||||
sha256 = clean.get("sha256")
|
||||
if isinstance(sha256, str) and _SHA256_RE.fullmatch(sha256.lower()) is None:
|
||||
raise ValueError(f"Module package catalog artifact_integrity.{key}.sha256 is invalid.")
|
||||
if isinstance(sha256, str):
|
||||
clean["sha256"] = sha256.lower()
|
||||
size = raw.get("size")
|
||||
if size is not None:
|
||||
if not isinstance(size, int) or isinstance(size, bool) or size <= 0 or size > 512 * 1024 * 1024:
|
||||
raise ValueError(f"Module package catalog artifact_integrity.{key}.size is invalid.")
|
||||
clean["size"] = size
|
||||
if clean:
|
||||
normalized[key] = clean
|
||||
return normalized
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, replace
|
||||
from typing import Any, Literal, Protocol, TYPE_CHECKING
|
||||
|
||||
from govoplan_core.core.information_governance import ModuleInformationGovernance
|
||||
from govoplan_core.core.ownership import OwnershipProviderRegistration
|
||||
from govoplan_core.core.provider_governance import (
|
||||
ExternalProviderDeclaration,
|
||||
@@ -14,16 +15,22 @@ from govoplan_core.core.views import ViewSurface
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import APIRouter
|
||||
from govoplan_core.core.operations import OperationalCheckProviderRegistration
|
||||
from govoplan_core.core.operations import (
|
||||
OperationalCheckProviderRegistration,
|
||||
RuntimeWorkStatusProviderRegistration,
|
||||
)
|
||||
from govoplan_core.core.search import (
|
||||
SearchProviderRegistration,
|
||||
SearchSourceProviderRegistration,
|
||||
)
|
||||
from govoplan_core.core.tasks import WorkItemProviderRegistration
|
||||
from govoplan_core.core.workflows import WorkflowDefinitionContribution
|
||||
|
||||
|
||||
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
|
||||
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION = "1"
|
||||
SUPPORTED_PRESENTATION_CONTRACT_VERSION = "1"
|
||||
SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION = "1"
|
||||
|
||||
PermissionLevel = Literal["system", "tenant"]
|
||||
SubjectType = Literal["account", "membership", "group", "service_account", "tenant"]
|
||||
@@ -33,7 +40,9 @@ MigrationTaskPhase = Literal[
|
||||
"post_migration_backfill",
|
||||
"post_migration_verify",
|
||||
]
|
||||
MigrationTaskSafety = Literal["automatic", "requires_review", "forward_only", "destructive"]
|
||||
MigrationTaskSafety = Literal[
|
||||
"automatic", "requires_review", "forward_only", "destructive"
|
||||
]
|
||||
MigrationTaskStatus = Literal["ok", "warning", "blocked", "skipped"]
|
||||
|
||||
|
||||
@@ -74,8 +83,6 @@ class NavItem:
|
||||
surface_id: str | None = None
|
||||
|
||||
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FrontendRoute:
|
||||
path: str
|
||||
@@ -95,6 +102,92 @@ class PublicFrontendRoute:
|
||||
order: int = 100
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProductAreaContribution:
|
||||
"""Assign module-owned surfaces to a user-facing product area."""
|
||||
|
||||
id: str
|
||||
module_id: str
|
||||
label: str
|
||||
icon: str
|
||||
surface_ids: tuple[str, ...]
|
||||
description: str | None = None
|
||||
order: int = 100
|
||||
|
||||
|
||||
ProductSurfacePresentation = Literal["task", "reader", "admin", "operator"]
|
||||
ProductAvailabilityReason = Literal[
|
||||
"authorization",
|
||||
"policy",
|
||||
"configuration",
|
||||
"disabled",
|
||||
"capability",
|
||||
"offline",
|
||||
"provider_degraded",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProductAvailabilityExplanation:
|
||||
"""Explain a product outcome without making package topology user-facing."""
|
||||
|
||||
reason: ProductAvailabilityReason
|
||||
title: str
|
||||
description: str
|
||||
resolution: str
|
||||
responsible_role: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProductSurfaceContribution:
|
||||
"""Bind an owner route to a stable, cross-module product identity."""
|
||||
|
||||
id: str
|
||||
module_id: str
|
||||
label: str
|
||||
icon: str
|
||||
entry_path: str
|
||||
route_path: str
|
||||
surface_ids: tuple[str, ...]
|
||||
unavailable: ProductAvailabilityExplanation
|
||||
description: str | None = None
|
||||
degraded: ProductAvailabilityExplanation | None = None
|
||||
presentations: tuple[ProductSurfacePresentation, ...] = ("task",)
|
||||
capability_ids: tuple[str, ...] = ()
|
||||
search_source_ids: tuple[str, ...] = ()
|
||||
help_context_ids: tuple[str, ...] = ()
|
||||
documentation_topic_ids: tuple[str, ...] = ()
|
||||
required_all: tuple[str, ...] = ()
|
||||
required_any: tuple[str, ...] = ()
|
||||
aliases: tuple[str, ...] = ()
|
||||
order: int = 100
|
||||
contract_version: str = SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class QuickAccessTool:
|
||||
"""Declare a versioned, bounded module-owned Quick Access tool."""
|
||||
|
||||
id: str
|
||||
module_id: str
|
||||
category_id: str
|
||||
label: str
|
||||
surface_id: str
|
||||
icon: str
|
||||
description: str | None = None
|
||||
full_page_path: str | None = None
|
||||
required_all: tuple[str, ...] = ()
|
||||
required_any: tuple[str, ...] = ()
|
||||
order: int = 100
|
||||
default_enabled: bool = True
|
||||
modes: tuple[str, ...] = ("browse",)
|
||||
contract_version: str = "1"
|
||||
availability: Literal["global", "active_object"] = "global"
|
||||
accepted_reference_kinds: tuple[str, ...] = ()
|
||||
returned_reference_kinds: tuple[str, ...] = ()
|
||||
help_context_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FrontendModule:
|
||||
module_id: str
|
||||
@@ -109,6 +202,9 @@ class FrontendModule:
|
||||
nav_items: tuple[NavItem, ...] = ()
|
||||
settings_routes: tuple[FrontendRoute, ...] = ()
|
||||
view_surfaces: tuple[ViewSurface, ...] = ()
|
||||
product_areas: tuple[ProductAreaContribution, ...] = ()
|
||||
product_surfaces: tuple[ProductSurfaceContribution, ...] = ()
|
||||
quick_access_tools: tuple[QuickAccessTool, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -146,7 +242,9 @@ class ModuleMigrationTaskResult:
|
||||
details: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
ModuleMigrationTaskExecutor = Callable[[ModuleMigrationTaskContext], ModuleMigrationTaskResult | None]
|
||||
ModuleMigrationTaskExecutor = Callable[
|
||||
[ModuleMigrationTaskContext], ModuleMigrationTaskResult | None
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -203,7 +301,9 @@ class ModuleUninstallGuardResult:
|
||||
message: str
|
||||
|
||||
|
||||
UninstallGuardProvider = Callable[[object | None, str], Iterable[ModuleUninstallGuardResult]]
|
||||
UninstallGuardProvider = Callable[
|
||||
[object | None, str], Iterable[ModuleUninstallGuardResult]
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -223,7 +323,9 @@ class ModuleContext:
|
||||
DocumentationLayer = Literal["always", "configured", "available", "evidence"]
|
||||
DocumentationLinkKind = Literal["runtime", "api", "repository", "wiki", "public"]
|
||||
DocumentationType = Literal["admin", "user"]
|
||||
DocumentationConfigurationState = Literal["enabled", "disabled", "inherited", "unavailable"]
|
||||
DocumentationConfigurationState = Literal[
|
||||
"enabled", "disabled", "inherited", "unavailable"
|
||||
]
|
||||
DocumentationSourceKind = Literal[
|
||||
"manifest",
|
||||
"route",
|
||||
@@ -238,6 +340,30 @@ DocumentationSourceState = Literal["configured", "disabled", "unavailable"]
|
||||
CapabilityStability = Literal["experimental", "stable", "deprecated"]
|
||||
|
||||
|
||||
DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION = "1"
|
||||
DOCUMENTATION_LOCALIZABLE_METADATA_KEYS = frozenset(
|
||||
{
|
||||
"admin_explanation",
|
||||
"consequence_classes",
|
||||
"consequences",
|
||||
"constraints",
|
||||
"current_configuration",
|
||||
"fields",
|
||||
"limitations",
|
||||
"operational_consequences",
|
||||
"outcome",
|
||||
"prerequisites",
|
||||
"privacy_notes",
|
||||
"purpose",
|
||||
"result",
|
||||
"steps",
|
||||
"user_explanation",
|
||||
"verification",
|
||||
"when_used",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DocumentationLink:
|
||||
label: str
|
||||
@@ -273,12 +399,161 @@ class DocumentationTopic:
|
||||
configuration_keys: tuple[str, ...] = ()
|
||||
i18n_key: str | None = None
|
||||
translations: Mapping[str, Mapping[str, str]] = field(default_factory=dict)
|
||||
structured_translation_version: str | None = None
|
||||
structured_translations: Mapping[str, Mapping[str, Any]] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
source_module_id: str | None = None
|
||||
version_min: str | None = None
|
||||
version_max_exclusive: str | None = None
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def localize_documentation_topics(
|
||||
topics: Iterable[DocumentationTopic],
|
||||
*,
|
||||
locale: str,
|
||||
translations: Mapping[str, Mapping[str, str]],
|
||||
) -> tuple[DocumentationTopic, ...]:
|
||||
"""Merge owner-supplied text translations without moving feature content."""
|
||||
localized: list[DocumentationTopic] = []
|
||||
for topic in topics:
|
||||
translated = translations.get(topic.id)
|
||||
if translated is None:
|
||||
localized.append(topic)
|
||||
continue
|
||||
values = {name: dict(value) for name, value in topic.translations.items()}
|
||||
values[locale] = {**values.get(locale, {}), **translated}
|
||||
localized.append(replace(topic, translations=values))
|
||||
return tuple(localized)
|
||||
|
||||
|
||||
def localizable_documentation_metadata_keys(
|
||||
topic: DocumentationTopic,
|
||||
) -> tuple[str, ...]:
|
||||
"""Return structured metadata keys whose values are public prose."""
|
||||
|
||||
return tuple(
|
||||
sorted(DOCUMENTATION_LOCALIZABLE_METADATA_KEYS.intersection(topic.metadata))
|
||||
)
|
||||
|
||||
|
||||
def localized_documentation_metadata(
|
||||
topic: DocumentationTopic,
|
||||
locale: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Overlay one validated structured translation onto source metadata."""
|
||||
|
||||
localized = dict(topic.metadata)
|
||||
translation = topic.structured_translations.get(locale)
|
||||
if translation:
|
||||
localized.update(translation)
|
||||
return localized
|
||||
|
||||
|
||||
def documentation_structured_translation_issues(
|
||||
topic: DocumentationTopic,
|
||||
) -> tuple[str, ...]:
|
||||
"""Validate the opt-in, versioned structured-documentation translation."""
|
||||
|
||||
version = topic.structured_translation_version
|
||||
translations = topic.structured_translations
|
||||
if version is None:
|
||||
if translations:
|
||||
return (
|
||||
"structured_translations require structured_translation_version",
|
||||
)
|
||||
return ()
|
||||
if version != DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION:
|
||||
return (
|
||||
"unsupported structured_translation_version "
|
||||
f"{version!r}; expected {DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION!r}",
|
||||
)
|
||||
|
||||
localizable_keys = set(localizable_documentation_metadata_keys(topic))
|
||||
issues: list[str] = []
|
||||
for locale, translation in translations.items():
|
||||
if not locale.strip():
|
||||
issues.append("structured translation locale must not be empty")
|
||||
continue
|
||||
translated_keys = set(translation)
|
||||
for key in sorted(translated_keys - localizable_keys):
|
||||
issues.append(
|
||||
f"structured translation {locale!r} contains non-localizable or missing metadata key {key!r}"
|
||||
)
|
||||
for key in sorted(localizable_keys - translated_keys):
|
||||
issues.append(
|
||||
f"structured translation {locale!r} is missing metadata key {key!r}"
|
||||
)
|
||||
for key in sorted(localizable_keys & translated_keys):
|
||||
issues.extend(
|
||||
_structured_translation_shape_issues(
|
||||
topic.metadata[key],
|
||||
translation[key],
|
||||
path=f"{locale}.{key}",
|
||||
)
|
||||
)
|
||||
return tuple(issues)
|
||||
|
||||
|
||||
def _structured_translation_shape_issues(
|
||||
source: object,
|
||||
translated: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> tuple[str, ...]:
|
||||
if isinstance(source, str):
|
||||
if not isinstance(translated, str) or not translated.strip():
|
||||
return (f"structured translation {path} must be a non-empty string",)
|
||||
return ()
|
||||
if isinstance(source, Mapping):
|
||||
if not isinstance(translated, Mapping):
|
||||
return (f"structured translation {path} must preserve object shape",)
|
||||
issues: list[str] = []
|
||||
source_keys = {str(key) for key in source}
|
||||
translated_keys = {str(key) for key in translated}
|
||||
if source_keys != translated_keys:
|
||||
issues.append(
|
||||
f"structured translation {path} must preserve object keys"
|
||||
)
|
||||
return tuple(issues)
|
||||
for key, value in source.items():
|
||||
issues.extend(
|
||||
_structured_translation_shape_issues(
|
||||
value,
|
||||
translated[key],
|
||||
path=f"{path}.{key}",
|
||||
)
|
||||
)
|
||||
return tuple(issues)
|
||||
if isinstance(source, Sequence) and not isinstance(
|
||||
source, (str, bytes, bytearray)
|
||||
):
|
||||
if not isinstance(translated, Sequence) or isinstance(
|
||||
translated, (str, bytes, bytearray)
|
||||
):
|
||||
return (f"structured translation {path} must preserve list shape",)
|
||||
if len(source) != len(translated):
|
||||
return (f"structured translation {path} must preserve list length",)
|
||||
issues: list[str] = []
|
||||
for index, (source_item, translated_item) in enumerate(
|
||||
zip(source, translated, strict=True)
|
||||
):
|
||||
issues.extend(
|
||||
_structured_translation_shape_issues(
|
||||
source_item,
|
||||
translated_item,
|
||||
path=f"{path}[{index}]",
|
||||
)
|
||||
)
|
||||
return tuple(issues)
|
||||
if translated != source:
|
||||
return (
|
||||
f"structured translation {path} must preserve non-text value {source!r}",
|
||||
)
|
||||
return ()
|
||||
|
||||
|
||||
def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str, ...]:
|
||||
"""Return fail-closed authoring issues for a user-facing workflow topic.
|
||||
|
||||
@@ -288,16 +563,23 @@ def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str
|
||||
"""
|
||||
|
||||
raw_kind = topic.metadata.get("kind")
|
||||
kind = raw_kind.strip().lower().replace("_", "-") if isinstance(raw_kind, str) else ""
|
||||
kind = (
|
||||
raw_kind.strip().lower().replace("_", "-") if isinstance(raw_kind, str) else ""
|
||||
)
|
||||
if kind != "workflow" or "user" not in topic.documentation_types:
|
||||
return ()
|
||||
if not topic.conditions:
|
||||
return ("user workflow topics must declare at least one scope-conditioned alternative",)
|
||||
return (
|
||||
"user workflow topics must declare at least one scope-conditioned alternative",
|
||||
)
|
||||
|
||||
unscoped_alternatives = tuple(
|
||||
index
|
||||
for index, condition in enumerate(topic.conditions, start=1)
|
||||
if not any(scope.strip() for scope in (*condition.required_scopes, *condition.any_scopes))
|
||||
if not any(
|
||||
scope.strip()
|
||||
for scope in (*condition.required_scopes, *condition.any_scopes)
|
||||
)
|
||||
)
|
||||
if not unscoped_alternatives:
|
||||
return ()
|
||||
@@ -369,14 +651,11 @@ class CapabilityDocumentation:
|
||||
class ResourceAclProvider(Protocol):
|
||||
resource_type: str
|
||||
|
||||
def can_read(self, principal: object, resource_id: str) -> bool:
|
||||
...
|
||||
def can_read(self, principal: object, resource_id: str) -> bool: ...
|
||||
|
||||
def can_write(self, principal: object, resource_id: str) -> bool:
|
||||
...
|
||||
def can_write(self, principal: object, resource_id: str) -> bool: ...
|
||||
|
||||
def explain(self, principal: object, resource_id: str) -> AccessDecision:
|
||||
...
|
||||
def explain(self, principal: object, resource_id: str) -> AccessDecision: ...
|
||||
|
||||
|
||||
TenantSummaryProvider = Callable[[object, str], Mapping[str, int]]
|
||||
@@ -408,6 +687,7 @@ class DeleteVetoProviderRegistration:
|
||||
|
||||
RouteFactory = Callable[[ModuleContext], "APIRouter"]
|
||||
CapabilityFactory = Callable[[ModuleContext], object]
|
||||
PublicTenantResolver = Callable[[object, object], str | None]
|
||||
DocumentationProvider = Callable[[DocumentationContext], Iterable[DocumentationTopic]]
|
||||
LifecycleHook = Callable[[ModuleContext], None]
|
||||
|
||||
@@ -426,6 +706,7 @@ class ModuleManifest:
|
||||
permissions: tuple[PermissionDefinition, ...] = ()
|
||||
role_templates: tuple[RoleTemplate, ...] = ()
|
||||
route_factory: RouteFactory | None = None
|
||||
public_tenant_resolver: PublicTenantResolver | None = None
|
||||
migration_spec: MigrationSpec | None = None
|
||||
nav_items: tuple[NavItem, ...] = ()
|
||||
frontend: FrontendModule | None = None
|
||||
@@ -433,17 +714,29 @@ class ModuleManifest:
|
||||
ownership_providers: tuple[OwnershipProviderRegistration, ...] = ()
|
||||
tenant_summary_providers: tuple[TenantSummaryProvider, ...] = ()
|
||||
tenant_summary_batch_providers: tuple[TenantSummaryBatchProvider, ...] = ()
|
||||
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
|
||||
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = ()
|
||||
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
|
||||
capability_documentation: Mapping[str, CapabilityDocumentation] = field(default_factory=dict)
|
||||
capability_documentation: Mapping[str, CapabilityDocumentation] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
search_providers: tuple["SearchProviderRegistration", ...] = ()
|
||||
search_sources: tuple["SearchSourceProviderRegistration", ...] = ()
|
||||
work_item_providers: tuple["WorkItemProviderRegistration", ...] = ()
|
||||
operational_check_providers: tuple[
|
||||
"OperationalCheckProviderRegistration",
|
||||
...,
|
||||
] = ()
|
||||
runtime_work_status_providers: tuple[
|
||||
"RuntimeWorkStatusProviderRegistration",
|
||||
...,
|
||||
] = ()
|
||||
architecture: ModuleArchitectureDeclaration | None = None
|
||||
information_governance: ModuleInformationGovernance = field(
|
||||
default_factory=ModuleInformationGovernance
|
||||
)
|
||||
external_providers: tuple[ExternalProviderDeclaration, ...] = ()
|
||||
external_provider_state_providers: tuple[
|
||||
ExternalProviderStateProviderRegistration,
|
||||
@@ -464,3 +757,53 @@ class ModuleManifest:
|
||||
# runtime module ID changes.
|
||||
permission_namespace: str | None = None
|
||||
workflow_definitions: tuple["WorkflowDefinitionContribution", ...] = ()
|
||||
|
||||
|
||||
def with_documentation_structured_translations(
|
||||
manifest: ModuleManifest,
|
||||
*,
|
||||
locale: str,
|
||||
translations: Mapping[str, Mapping[str, Any]],
|
||||
) -> ModuleManifest:
|
||||
"""Merge module-owned structured documentation translations by topic id.
|
||||
|
||||
The helper keeps feature prose in its owning module while giving every
|
||||
manifest the same fail-closed merge behavior. Unknown topic ids and
|
||||
incomplete or shape-changing locale maps are rejected immediately.
|
||||
"""
|
||||
|
||||
locale = locale.strip()
|
||||
if not locale:
|
||||
raise ValueError("structured documentation locale must not be empty")
|
||||
|
||||
topics_by_id = {topic.id: topic for topic in manifest.documentation}
|
||||
unknown_topic_ids = sorted(set(translations) - set(topics_by_id))
|
||||
if unknown_topic_ids:
|
||||
raise ValueError(
|
||||
"structured documentation translations reference unknown topic ids: "
|
||||
+ ", ".join(unknown_topic_ids)
|
||||
)
|
||||
|
||||
localized_topics: list[DocumentationTopic] = []
|
||||
for topic in manifest.documentation:
|
||||
translation = translations.get(topic.id)
|
||||
if translation is None:
|
||||
localized_topics.append(topic)
|
||||
continue
|
||||
|
||||
structured_translations = dict(topic.structured_translations)
|
||||
structured_translations[locale] = translation
|
||||
localized_topic = replace(
|
||||
topic,
|
||||
structured_translation_version=DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION,
|
||||
structured_translations=structured_translations,
|
||||
)
|
||||
issues = documentation_structured_translation_issues(localized_topic)
|
||||
if issues:
|
||||
raise ValueError(
|
||||
f"invalid {locale!r} structured documentation translation for "
|
||||
f"{topic.id!r}: {'; '.join(issues)}"
|
||||
)
|
||||
localized_topics.append(localized_topic)
|
||||
|
||||
return replace(manifest, documentation=tuple(localized_topics))
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
NAVIGATION_PREFERENCES_KEY = "navigation_preferences"
|
||||
NAVIGATION_PREFERENCES_CONTRACT_VERSION = "1"
|
||||
_MAX_ITEMS = 256
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NavigationSeparator:
|
||||
id: str
|
||||
label: str = ""
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
return {"id": self.id, "label": self.label}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NavigationPreferences:
|
||||
order: tuple[str, ...] = ()
|
||||
hidden: tuple[str, ...] = ()
|
||||
locked: tuple[str, ...] = ()
|
||||
# None preserves inherited grouping; an empty tuple explicitly removes it.
|
||||
separators: tuple[NavigationSeparator, ...] | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"contract_version": NAVIGATION_PREFERENCES_CONTRACT_VERSION,
|
||||
"order": list(self.order),
|
||||
"hidden": list(self.hidden),
|
||||
"locked": list(self.locked),
|
||||
**({"separators": [item.as_dict() for item in self.separators]} if self.separators is not None else {}),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EffectiveNavigationItem:
|
||||
id: str
|
||||
order: int
|
||||
visible: bool
|
||||
locked: bool
|
||||
order_source: str
|
||||
visibility_source: str
|
||||
lock_source: str | None = None
|
||||
section: NavigationSeparator | None = None
|
||||
custom_layout: bool = False
|
||||
layout_source: str = "module"
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"navigation_id": self.id,
|
||||
"order": self.order,
|
||||
"navigation_visible": self.visible,
|
||||
"navigation_locked": self.locked,
|
||||
"navigation_order_source": self.order_source,
|
||||
"navigation_visibility_source": self.visibility_source,
|
||||
"navigation_lock_source": self.lock_source,
|
||||
"navigation_section": self.section.as_dict() if self.section else None,
|
||||
"navigation_custom_layout": self.custom_layout,
|
||||
"navigation_layout_source": self.layout_source,
|
||||
}
|
||||
|
||||
|
||||
def navigation_preferences_from_settings(
|
||||
settings: object,
|
||||
) -> NavigationPreferences | None:
|
||||
if not isinstance(settings, Mapping):
|
||||
return None
|
||||
raw = settings.get(NAVIGATION_PREFERENCES_KEY)
|
||||
if not isinstance(raw, Mapping):
|
||||
return None
|
||||
return navigation_preferences_from_mapping(raw)
|
||||
|
||||
|
||||
def navigation_preferences_from_mapping(
|
||||
raw: Mapping[str, Any],
|
||||
) -> NavigationPreferences:
|
||||
return NavigationPreferences(
|
||||
order=_ids(raw.get("order")),
|
||||
hidden=_ids(raw.get("hidden")),
|
||||
locked=_ids(raw.get("locked")),
|
||||
separators=_separators(raw.get("separators")),
|
||||
)
|
||||
|
||||
|
||||
def update_navigation_preferences(
|
||||
settings: object,
|
||||
preferences: NavigationPreferences | Mapping[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
updated = dict(settings) if isinstance(settings, Mapping) else {}
|
||||
if preferences is None:
|
||||
updated.pop(NAVIGATION_PREFERENCES_KEY, None)
|
||||
else:
|
||||
raw = preferences.as_dict() if isinstance(preferences, NavigationPreferences) else preferences
|
||||
updated[NAVIGATION_PREFERENCES_KEY] = navigation_preferences_from_mapping(
|
||||
raw
|
||||
).as_dict()
|
||||
return updated
|
||||
|
||||
|
||||
def resolve_navigation_preferences(
|
||||
item_ids: Iterable[str],
|
||||
*,
|
||||
system: NavigationPreferences | None = None,
|
||||
tenant: NavigationPreferences | None = None,
|
||||
user: NavigationPreferences | None = None,
|
||||
) -> dict[str, EffectiveNavigationItem]:
|
||||
ordered = list(dict.fromkeys(_clean_id(item_id) for item_id in item_ids))
|
||||
ordered = [item_id for item_id in ordered if item_id]
|
||||
available = set(ordered)
|
||||
order_source = {item_id: "module" for item_id in ordered}
|
||||
visibility = {item_id: True for item_id in ordered}
|
||||
visibility_source = {item_id: "module" for item_id in ordered}
|
||||
locks: dict[str, str] = {}
|
||||
separators: dict[str, NavigationSeparator] = {}
|
||||
custom_layout = False
|
||||
layout_source = "module"
|
||||
|
||||
for source, preferences, may_lock in (
|
||||
("system", system, True),
|
||||
("tenant", tenant, True),
|
||||
("user", user, False),
|
||||
):
|
||||
if preferences is None:
|
||||
continue
|
||||
if preferences.separators is not None:
|
||||
separators = {item.id: item for item in preferences.separators if item.id not in available}
|
||||
ordered = [item_id for item_id in ordered if item_id in available or item_id in separators]
|
||||
ordered.extend(item_id for item_id in separators if item_id not in ordered)
|
||||
custom_layout = True
|
||||
layout_source = source
|
||||
requested_order = list(dict.fromkeys(item_id for item_id in preferences.order if item_id in available or item_id in separators))
|
||||
if requested_order:
|
||||
requested = set(requested_order)
|
||||
ordered = [*requested_order, *(item_id for item_id in ordered if item_id not in requested)]
|
||||
for item_id in requested_order:
|
||||
order_source[item_id] = source
|
||||
|
||||
requested_hidden = set(preferences.hidden).intersection(available)
|
||||
for item_id in available:
|
||||
if item_id in locks:
|
||||
visibility[item_id] = True
|
||||
visibility_source[item_id] = locks[item_id]
|
||||
continue
|
||||
visibility[item_id] = item_id not in requested_hidden
|
||||
visibility_source[item_id] = source
|
||||
|
||||
if may_lock:
|
||||
for item_id in preferences.locked:
|
||||
if item_id not in available:
|
||||
continue
|
||||
locks[item_id] = source
|
||||
visibility[item_id] = True
|
||||
visibility_source[item_id] = source
|
||||
|
||||
result: dict[str, EffectiveNavigationItem] = {}
|
||||
section: NavigationSeparator | None = None
|
||||
for index, item_id in enumerate(ordered):
|
||||
if item_id in separators:
|
||||
section = separators[item_id]
|
||||
continue
|
||||
result[item_id] = EffectiveNavigationItem(
|
||||
id=item_id,
|
||||
order=index,
|
||||
visible=visibility[item_id],
|
||||
locked=item_id in locks,
|
||||
order_source=order_source[item_id],
|
||||
visibility_source=visibility_source[item_id],
|
||||
lock_source=locks.get(item_id),
|
||||
section=section,
|
||||
custom_layout=custom_layout,
|
||||
layout_source=layout_source,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _separators(value: object) -> tuple[NavigationSeparator, ...] | None:
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return None
|
||||
items: dict[str, NavigationSeparator] = {}
|
||||
for raw in value[:_MAX_ITEMS]:
|
||||
if not isinstance(raw, Mapping):
|
||||
continue
|
||||
item_id = _clean_id(raw.get("id"))
|
||||
label = raw.get("label", "")
|
||||
if not item_id.startswith("separator:") or not isinstance(label, str):
|
||||
continue
|
||||
label = label.strip()[:120]
|
||||
if any(ord(character) < 32 for character in label):
|
||||
continue
|
||||
items[item_id] = NavigationSeparator(item_id, label)
|
||||
return tuple(items.values())
|
||||
|
||||
|
||||
def _ids(value: object) -> tuple[str, ...]:
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return ()
|
||||
cleaned = tuple(
|
||||
dict.fromkeys(
|
||||
item_id
|
||||
for item in value[:_MAX_ITEMS]
|
||||
if (item_id := _clean_id(item))
|
||||
)
|
||||
)
|
||||
return cleaned
|
||||
|
||||
|
||||
def _clean_id(value: object) -> str:
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
clean = value.strip()
|
||||
if not clean or len(clean) > 255 or any(ord(character) < 32 for character in clean):
|
||||
return ""
|
||||
return clean
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EffectiveNavigationItem",
|
||||
"NAVIGATION_PREFERENCES_CONTRACT_VERSION",
|
||||
"NAVIGATION_PREFERENCES_KEY",
|
||||
"NavigationPreferences",
|
||||
"NavigationSeparator",
|
||||
"navigation_preferences_from_mapping",
|
||||
"navigation_preferences_from_settings",
|
||||
"resolve_navigation_preferences",
|
||||
"update_navigation_preferences",
|
||||
]
|
||||
@@ -33,6 +33,14 @@ class NotificationDispatchRequest:
|
||||
|
||||
@runtime_checkable
|
||||
class NotificationDispatchProvider(Protocol):
|
||||
def tenant_id_for_notification(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
notification_id: str,
|
||||
) -> str | None:
|
||||
...
|
||||
|
||||
def enqueue_notification(
|
||||
self,
|
||||
session: object,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from heapq import nsmallest
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -27,6 +28,7 @@ class StorageObjectMissing(StorageBackendError):
|
||||
class StorageObjectInfo:
|
||||
key: str
|
||||
size_bytes: int
|
||||
modified_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -165,9 +167,14 @@ class LocalFilesystemStorageBackend:
|
||||
|
||||
def stat(self, key: str) -> StorageObjectInfo:
|
||||
path = self._readable_path(key)
|
||||
metadata = path.stat()
|
||||
return StorageObjectInfo(
|
||||
key=normalize_storage_key(key),
|
||||
size_bytes=path.stat().st_size,
|
||||
size_bytes=metadata.st_size,
|
||||
modified_at=datetime.fromtimestamp(
|
||||
metadata.st_mtime,
|
||||
tz=timezone.utc,
|
||||
),
|
||||
)
|
||||
|
||||
def list_objects(
|
||||
@@ -188,9 +195,14 @@ class LocalFilesystemStorageBackend:
|
||||
normalized_after is not None and key <= normalized_after
|
||||
):
|
||||
continue
|
||||
metadata = path.stat()
|
||||
yield StorageObjectInfo(
|
||||
key=key,
|
||||
size_bytes=path.stat().st_size,
|
||||
size_bytes=metadata.st_size,
|
||||
modified_at=datetime.fromtimestamp(
|
||||
metadata.st_mtime,
|
||||
tz=timezone.utc,
|
||||
),
|
||||
)
|
||||
|
||||
candidates = nsmallest(
|
||||
@@ -381,7 +393,11 @@ class S3StorageBackend:
|
||||
raise StorageBackendError(
|
||||
"S3 object metadata did not include a valid size"
|
||||
) from exc
|
||||
return StorageObjectInfo(key=normalized, size_bytes=size)
|
||||
return StorageObjectInfo(
|
||||
key=normalized,
|
||||
size_bytes=size,
|
||||
modified_at=_storage_modified_at(response.get("LastModified")),
|
||||
)
|
||||
|
||||
def list_objects(
|
||||
self,
|
||||
@@ -407,6 +423,7 @@ class S3StorageBackend:
|
||||
StorageObjectInfo(
|
||||
key=str(item["Key"]),
|
||||
size_bytes=int(item.get("Size") or 0),
|
||||
modified_at=_storage_modified_at(item.get("LastModified")),
|
||||
)
|
||||
for item in response.get("Contents", ())
|
||||
if isinstance(item, dict) and item.get("Key")
|
||||
@@ -418,6 +435,14 @@ class S3StorageBackend:
|
||||
)
|
||||
|
||||
|
||||
def _storage_modified_at(value: object) -> datetime | None:
|
||||
if not isinstance(value, datetime):
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def configured_storage_backend(settings: object) -> StorageBackend:
|
||||
"""Build the deployment-wide object store from Core settings.
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
|
||||
@@ -42,3 +43,84 @@ class OperationalCheckProviderRegistration:
|
||||
provider: OperationalCheckProvider
|
||||
cache_seconds: int = 60
|
||||
|
||||
|
||||
RuntimeWorkState = Literal[
|
||||
"disabled",
|
||||
"unconfigured",
|
||||
"starting",
|
||||
"healthy",
|
||||
"idle",
|
||||
"busy",
|
||||
"degraded",
|
||||
"stale",
|
||||
"unreachable",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RuntimeWorkStatusContext:
|
||||
"""Sanitized process evidence supplied to a runtime-work provider."""
|
||||
|
||||
profile: str
|
||||
observed_at: datetime
|
||||
stale_after_seconds: int
|
||||
runtime_nodes: Sequence[Mapping[str, object]] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RuntimeWorkStatus:
|
||||
"""Bounded worker/queue status with explicit unsupported metrics."""
|
||||
|
||||
provider_id: str
|
||||
label: str
|
||||
backend: str
|
||||
enabled: bool
|
||||
configured: bool
|
||||
state: RuntimeWorkState
|
||||
detail: str
|
||||
observed_at: datetime
|
||||
active_workers: int | None = None
|
||||
last_heartbeat_at: datetime | None = None
|
||||
queue_depths: Mapping[str, int | None] = field(default_factory=dict)
|
||||
active_work: int | None = None
|
||||
reserved_work: int | None = None
|
||||
failures: int | None = None
|
||||
stale_after_seconds: int | None = None
|
||||
guidance: str = ""
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"provider_id": self.provider_id,
|
||||
"label": self.label,
|
||||
"backend": self.backend,
|
||||
"enabled": self.enabled,
|
||||
"configured": self.configured,
|
||||
"state": self.state,
|
||||
"detail": self.detail,
|
||||
"observed_at": self.observed_at.isoformat(),
|
||||
"active_workers": self.active_workers,
|
||||
"last_heartbeat_at": (
|
||||
self.last_heartbeat_at.isoformat()
|
||||
if self.last_heartbeat_at is not None
|
||||
else None
|
||||
),
|
||||
"queue_depths": dict(self.queue_depths),
|
||||
"active_work": self.active_work,
|
||||
"reserved_work": self.reserved_work,
|
||||
"failures": self.failures,
|
||||
"stale_after_seconds": self.stale_after_seconds,
|
||||
"guidance": self.guidance,
|
||||
}
|
||||
|
||||
|
||||
RuntimeWorkStatusProvider = Callable[[RuntimeWorkStatusContext], RuntimeWorkStatus]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RuntimeWorkStatusProviderRegistration:
|
||||
"""Register one optional provider-neutral worker/queue observation."""
|
||||
|
||||
module_id: str
|
||||
provider_id: str
|
||||
provider: RuntimeWorkStatusProvider
|
||||
cache_seconds: int = 15
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from govoplan_core.core.institutional import EvidenceReference
|
||||
|
||||
|
||||
CAPABILITY_PAYMENT_REQUESTS = "payments.requests"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PaymentRequestCommand:
|
||||
tenant_id: str
|
||||
source_module: str
|
||||
source_resource_type: str
|
||||
source_resource_id: str
|
||||
amount_minor: int
|
||||
currency: str
|
||||
subject: str
|
||||
idempotency_key: str
|
||||
requested_at: datetime
|
||||
requested_by_ref: str
|
||||
due_at: datetime | None = None
|
||||
context_refs: Mapping[str, str] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ManualPaymentReconciliationCommand:
|
||||
tenant_id: str
|
||||
payment_id: str
|
||||
amount_minor: int
|
||||
currency: str
|
||||
transaction_reference: str
|
||||
evidence_ref: EvidenceReference
|
||||
idempotency_key: str
|
||||
received_at: datetime
|
||||
recorded_at: datetime
|
||||
recorded_by_ref: str
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PaymentRequestProvider(Protocol):
|
||||
def request_payment(
|
||||
self,
|
||||
session: object,
|
||||
command: PaymentRequestCommand,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
def get_payment(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
payment_id: str,
|
||||
) -> Mapping[str, object] | None:
|
||||
...
|
||||
|
||||
def reconcile_manual_payment(
|
||||
self,
|
||||
session: object,
|
||||
command: ManualPaymentReconciliationCommand,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
|
||||
def payment_request_provider(registry: object | None) -> PaymentRequestProvider | None:
|
||||
if registry is None or not hasattr(registry, "has_capability"):
|
||||
return None
|
||||
if not registry.has_capability(CAPABILITY_PAYMENT_REQUESTS):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_PAYMENT_REQUESTS)
|
||||
return capability if isinstance(capability, PaymentRequestProvider) else None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_PAYMENT_REQUESTS",
|
||||
"ManualPaymentReconciliationCommand",
|
||||
"PaymentRequestCommand",
|
||||
"PaymentRequestProvider",
|
||||
"payment_request_provider",
|
||||
]
|
||||
@@ -0,0 +1,380 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Literal, Mapping, Sequence
|
||||
|
||||
from govoplan_core.core.modules import ModuleManifest
|
||||
from govoplan_core.core.views import (
|
||||
navigation_view_surface_id,
|
||||
route_view_surface_id,
|
||||
)
|
||||
|
||||
|
||||
PLATFORM_INTERFACE_CONTRACT_VERSION = "1"
|
||||
|
||||
PlatformInterfaceKind = Literal[
|
||||
"backend_capability",
|
||||
"frontend_route",
|
||||
"navigation",
|
||||
"permission",
|
||||
"product_area",
|
||||
"product_surface",
|
||||
"provided_interface",
|
||||
"public_route",
|
||||
"search_provider",
|
||||
"search_source",
|
||||
"settings_route",
|
||||
"quick_access_tool",
|
||||
"view_surface",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PlatformInterfaceDeclaration:
|
||||
"""A sanitized, stable declaration from a module manifest.
|
||||
|
||||
The declaration contains identifiers and authorization metadata only. It
|
||||
deliberately excludes factories, executable callbacks, credentials, and
|
||||
mutable module state.
|
||||
"""
|
||||
|
||||
id: str
|
||||
module_id: str
|
||||
kind: PlatformInterfaceKind
|
||||
label: str | None = None
|
||||
path: str | None = None
|
||||
required_all: tuple[str, ...] = ()
|
||||
required_any: tuple[str, ...] = ()
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
return f"{self.kind}:{self.id}"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"key": self.key,
|
||||
"id": self.id,
|
||||
"module_id": self.module_id,
|
||||
"kind": self.kind,
|
||||
"label": self.label,
|
||||
"path": self.path,
|
||||
"required_all": list(self.required_all),
|
||||
"required_any": list(self.required_any),
|
||||
"metadata": dict(self.metadata),
|
||||
}
|
||||
|
||||
|
||||
def manifest_interface_declarations(
|
||||
manifest: ModuleManifest,
|
||||
) -> tuple[PlatformInterfaceDeclaration, ...]:
|
||||
"""Normalize the typed public declarations owned by one module manifest."""
|
||||
|
||||
declarations: list[PlatformInterfaceDeclaration] = []
|
||||
|
||||
for capability_name in sorted(manifest.capability_factories):
|
||||
documentation = manifest.capability_documentation.get(capability_name)
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=capability_name,
|
||||
module_id=manifest.id,
|
||||
kind="backend_capability",
|
||||
label=documentation.label if documentation is not None else None,
|
||||
metadata={
|
||||
"contract_version": (
|
||||
documentation.contract_version
|
||||
if documentation is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
for interface in manifest.provides_interfaces:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=interface.name,
|
||||
module_id=manifest.id,
|
||||
kind="provided_interface",
|
||||
metadata={"version": interface.version},
|
||||
)
|
||||
)
|
||||
|
||||
for permission in manifest.permissions:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=permission.scope,
|
||||
module_id=manifest.id,
|
||||
kind="permission",
|
||||
label=permission.label,
|
||||
metadata={
|
||||
"category": permission.category,
|
||||
"level": permission.level,
|
||||
"deprecated": permission.deprecated,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
for registration in manifest.search_providers:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=registration.id,
|
||||
module_id=manifest.id,
|
||||
kind="search_provider",
|
||||
metadata={
|
||||
"role": "provider",
|
||||
"resource_types": list(registration.resource_types),
|
||||
},
|
||||
)
|
||||
)
|
||||
for registration in manifest.search_sources:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=registration.id,
|
||||
module_id=manifest.id,
|
||||
kind="search_source",
|
||||
metadata={"role": "source"},
|
||||
)
|
||||
)
|
||||
|
||||
frontend = manifest.frontend
|
||||
if frontend is not None:
|
||||
for route in frontend.routes:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=route_view_surface_id(manifest.id, route.path),
|
||||
module_id=manifest.id,
|
||||
kind="frontend_route",
|
||||
path=route.path,
|
||||
required_all=route.required_all,
|
||||
required_any=route.required_any,
|
||||
metadata={
|
||||
"component": route.component,
|
||||
"order": route.order,
|
||||
"surface_id": route.surface_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
for route in frontend.public_routes:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=f"{manifest.id}.public.{_path_slug(route.path)}",
|
||||
module_id=manifest.id,
|
||||
kind="public_route",
|
||||
path=route.path,
|
||||
metadata={"component": route.component, "order": route.order},
|
||||
)
|
||||
)
|
||||
for route in frontend.settings_routes:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=route_view_surface_id(manifest.id, route.path),
|
||||
module_id=manifest.id,
|
||||
kind="settings_route",
|
||||
path=route.path,
|
||||
required_all=route.required_all,
|
||||
required_any=route.required_any,
|
||||
metadata={
|
||||
"component": route.component,
|
||||
"order": route.order,
|
||||
"surface_id": route.surface_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
for item in frontend.nav_items:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=navigation_view_surface_id(manifest.id, item.path),
|
||||
module_id=manifest.id,
|
||||
kind="navigation",
|
||||
label=item.label,
|
||||
path=item.path,
|
||||
required_all=item.required_all,
|
||||
required_any=item.required_any,
|
||||
metadata={
|
||||
"icon": item.icon,
|
||||
"section": item.section,
|
||||
"order": item.order,
|
||||
"surface_id": item.surface_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
for surface in frontend.view_surfaces:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=surface.id,
|
||||
module_id=manifest.id,
|
||||
kind="view_surface",
|
||||
label=surface.label,
|
||||
metadata={
|
||||
"surface_kind": surface.kind,
|
||||
"parent_id": surface.parent_id,
|
||||
"description": surface.description,
|
||||
"order": surface.order,
|
||||
"default_visible": surface.default_visible,
|
||||
"required": surface.required,
|
||||
},
|
||||
)
|
||||
)
|
||||
for area in frontend.product_areas:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=f"{manifest.id}.{area.id}",
|
||||
module_id=manifest.id,
|
||||
kind="product_area",
|
||||
label=area.label,
|
||||
required_all=(),
|
||||
required_any=(),
|
||||
metadata={
|
||||
"area_id": area.id,
|
||||
"icon": area.icon,
|
||||
"description": area.description,
|
||||
"order": area.order,
|
||||
"surface_ids": list(area.surface_ids),
|
||||
},
|
||||
)
|
||||
)
|
||||
for surface in frontend.product_surfaces:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=f"{manifest.id}.{surface.id}",
|
||||
module_id=manifest.id,
|
||||
kind="product_surface",
|
||||
label=surface.label,
|
||||
path=surface.route_path,
|
||||
required_all=surface.required_all,
|
||||
required_any=surface.required_any,
|
||||
metadata={
|
||||
"contract_version": surface.contract_version,
|
||||
"product_surface_id": surface.id,
|
||||
"description": surface.description,
|
||||
"icon": surface.icon,
|
||||
"entry_path": surface.entry_path,
|
||||
"surface_ids": list(surface.surface_ids),
|
||||
"presentations": list(surface.presentations),
|
||||
"capability_ids": list(surface.capability_ids),
|
||||
"search_source_ids": list(surface.search_source_ids),
|
||||
"help_context_ids": list(surface.help_context_ids),
|
||||
"documentation_topic_ids": list(
|
||||
surface.documentation_topic_ids
|
||||
),
|
||||
"aliases": list(surface.aliases),
|
||||
"order": surface.order,
|
||||
"unavailable_reason": surface.unavailable.reason,
|
||||
"degraded_reason": (
|
||||
surface.degraded.reason
|
||||
if surface.degraded is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
for tool in frontend.quick_access_tools:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=tool.id,
|
||||
module_id=manifest.id,
|
||||
kind="quick_access_tool",
|
||||
label=tool.label,
|
||||
path=tool.full_page_path,
|
||||
required_all=tool.required_all,
|
||||
required_any=tool.required_any,
|
||||
metadata={
|
||||
"category_id": tool.category_id,
|
||||
"surface_id": tool.surface_id,
|
||||
"icon": tool.icon,
|
||||
"description": tool.description,
|
||||
"order": tool.order,
|
||||
"default_enabled": tool.default_enabled,
|
||||
"modes": list(tool.modes),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
frontend_navigation = {
|
||||
declaration.id: declaration
|
||||
for declaration in declarations
|
||||
if declaration.kind == "navigation"
|
||||
}
|
||||
for item in manifest.nav_items:
|
||||
declaration = PlatformInterfaceDeclaration(
|
||||
id=navigation_view_surface_id(manifest.id, item.path),
|
||||
module_id=manifest.id,
|
||||
kind="navigation",
|
||||
label=item.label,
|
||||
path=item.path,
|
||||
required_all=item.required_all,
|
||||
required_any=item.required_any,
|
||||
metadata={
|
||||
"icon": item.icon,
|
||||
"section": item.section,
|
||||
"order": item.order,
|
||||
"surface_id": item.surface_id,
|
||||
},
|
||||
)
|
||||
frontend_declaration = frontend_navigation.get(declaration.id)
|
||||
if frontend_declaration is not None and frontend_declaration == declaration:
|
||||
continue
|
||||
declarations.append(declaration)
|
||||
|
||||
return tuple(sorted(declarations, key=lambda item: (item.kind, item.id)))
|
||||
|
||||
|
||||
def validate_manifest_interface_declarations(manifest: ModuleManifest) -> None:
|
||||
seen: set[str] = set()
|
||||
for declaration in manifest_interface_declarations(manifest):
|
||||
if declaration.key in seen:
|
||||
raise ValueError(
|
||||
f"Module {manifest.id!r} declares duplicate platform interface "
|
||||
f"{declaration.key!r}"
|
||||
)
|
||||
seen.add(declaration.key)
|
||||
|
||||
|
||||
def manifest_interface_catalog(manifest: ModuleManifest) -> dict[str, Any]:
|
||||
declarations = manifest_interface_declarations(manifest)
|
||||
serialized = [item.to_dict() for item in declarations]
|
||||
canonical = json.dumps(
|
||||
serialized,
|
||||
ensure_ascii=True,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
).encode("utf-8")
|
||||
return {
|
||||
"contract_version": PLATFORM_INTERFACE_CONTRACT_VERSION,
|
||||
"module_id": manifest.id,
|
||||
"module_version": manifest.version,
|
||||
"digest": f"sha256:{hashlib.sha256(canonical).hexdigest()}",
|
||||
"counts": dict(sorted(Counter(item.kind for item in declarations).items())),
|
||||
"declarations": serialized,
|
||||
}
|
||||
|
||||
|
||||
def platform_interface_catalog(
|
||||
manifests: Sequence[ModuleManifest],
|
||||
) -> dict[str, Any]:
|
||||
modules = [manifest_interface_catalog(manifest) for manifest in manifests]
|
||||
return {
|
||||
"contract_version": PLATFORM_INTERFACE_CONTRACT_VERSION,
|
||||
"modules": modules,
|
||||
}
|
||||
|
||||
|
||||
def _path_slug(path: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", ".", path.lower()).strip(".")
|
||||
return slug or "root"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PLATFORM_INTERFACE_CONTRACT_VERSION",
|
||||
"PlatformInterfaceDeclaration",
|
||||
"PlatformInterfaceKind",
|
||||
"manifest_interface_catalog",
|
||||
"manifest_interface_declarations",
|
||||
"platform_interface_catalog",
|
||||
"validate_manifest_interface_declarations",
|
||||
]
|
||||
@@ -7,6 +7,20 @@ from urllib.parse import quote, unquote
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
|
||||
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
|
||||
PolicyImpactPopulationState = Literal[
|
||||
"complete",
|
||||
"sampled",
|
||||
"truncated",
|
||||
"unavailable",
|
||||
]
|
||||
CampaignArchiveEncryptionMethod = Literal["aes", "zip_standard"]
|
||||
CampaignArchivePasswordDeliveryChannel = Literal[
|
||||
"separate_mail",
|
||||
"sms",
|
||||
"letter",
|
||||
"phone",
|
||||
"in_person",
|
||||
]
|
||||
SchedulingParticipantVisibility = Literal["aggregates_only", "names_and_statuses"]
|
||||
DefinitionScopeType = Literal["system", "tenant", "group", "user"]
|
||||
DefinitionKind = Literal["flow", "template"]
|
||||
@@ -27,10 +41,12 @@ ViewGovernanceAction = Literal[
|
||||
"workflow_activate",
|
||||
]
|
||||
FunctionAssignmentChangeKind = Literal["request", "grant"]
|
||||
FunctionAssignmentReviewStep = Literal["holder", "authority", "recipient"]
|
||||
FunctionAssignmentGovernanceAction = Literal[
|
||||
"submit",
|
||||
"approve_holder",
|
||||
"approve_authority",
|
||||
"approve_escalation",
|
||||
"accept_recipient",
|
||||
"request_changes",
|
||||
"respond",
|
||||
@@ -45,6 +61,8 @@ CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY = "policy.schedulingParticipant
|
||||
CAPABILITY_POLICY_DEFINITION_GOVERNANCE = "policy.definitionGovernance"
|
||||
CAPABILITY_POLICY_VIEW_GOVERNANCE = "policy.viewGovernance"
|
||||
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE = "policy.functionAssignmentGovernance"
|
||||
CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION = "policy.campaignArchiveEncryption"
|
||||
CAPABILITY_POLICY_IMPACT_SUBJECT_PREFIX = "policy.impactSubjects."
|
||||
|
||||
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = (
|
||||
"system",
|
||||
@@ -182,6 +200,213 @@ class PolicyDecision:
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PolicyImpactPopulationRequest:
|
||||
"""One explicit, bounded request to an optional impact-subject provider."""
|
||||
|
||||
tenant_id: str
|
||||
policy_family: str
|
||||
selector: Mapping[str, Any] = field(default_factory=dict)
|
||||
limit: int = 200
|
||||
actor_scopes: tuple[str, ...] = ()
|
||||
allow_sensitive_details: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.tenant_id.strip():
|
||||
raise ValueError("Policy impact population requires a tenant ID")
|
||||
if not self.policy_family.strip() or len(self.policy_family) > 120:
|
||||
raise ValueError(
|
||||
"Policy impact population family must contain 1 to 120 characters"
|
||||
)
|
||||
if self.limit < 1 or self.limit > 500:
|
||||
raise ValueError("Policy impact population limit must be between 1 and 500")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PolicyImpactSubject:
|
||||
"""Provider-owned reference safe for Policy to compare without domain imports."""
|
||||
|
||||
module_id: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
action: str
|
||||
label: str | None = None
|
||||
scope_type: PolicyScopeType | None = None
|
||||
scope_id: str | None = None
|
||||
attributes: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for label, value, maximum in (
|
||||
("module ID", self.module_id, 80),
|
||||
("resource type", self.resource_type, 80),
|
||||
("resource ID", self.resource_id, 240),
|
||||
("action", self.action, 120),
|
||||
):
|
||||
if not value.strip() or len(value) > maximum:
|
||||
raise ValueError(
|
||||
f"Policy impact subject {label} must contain 1 to {maximum} characters"
|
||||
)
|
||||
|
||||
@property
|
||||
def key(self) -> tuple[str, str, str, str]:
|
||||
return (
|
||||
self.module_id,
|
||||
self.resource_type,
|
||||
self.resource_id,
|
||||
self.action,
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"module_id": self.module_id,
|
||||
"resource_type": self.resource_type,
|
||||
"resource_id": self.resource_id,
|
||||
"action": self.action,
|
||||
"label": self.label,
|
||||
"scope_type": self.scope_type,
|
||||
"scope_id": self.scope_id,
|
||||
"attributes": dict(self.attributes),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PolicyImpactSubjectBatch:
|
||||
provider_id: str
|
||||
subjects: tuple[PolicyImpactSubject, ...] = ()
|
||||
state: PolicyImpactPopulationState = "complete"
|
||||
total_available: int | None = None
|
||||
explanation: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.provider_id.strip() or len(self.provider_id) > 120:
|
||||
raise ValueError(
|
||||
"Policy impact provider ID must contain 1 to 120 characters"
|
||||
)
|
||||
if len(self.subjects) > 500:
|
||||
raise ValueError("Policy impact providers may return at most 500 subjects")
|
||||
if len({subject.key for subject in self.subjects}) != len(self.subjects):
|
||||
raise ValueError("Policy impact provider returned duplicate subjects")
|
||||
if self.total_available is not None and self.total_available < len(self.subjects):
|
||||
raise ValueError(
|
||||
"Policy impact population total cannot be smaller than its subjects"
|
||||
)
|
||||
if self.state == "unavailable" and not self.explanation:
|
||||
raise ValueError("Unavailable policy impact populations need an explanation")
|
||||
|
||||
def to_dict(self, *, include_subjects: bool = True) -> dict[str, Any]:
|
||||
return {
|
||||
"provider_id": self.provider_id,
|
||||
"state": self.state,
|
||||
"returned": len(self.subjects),
|
||||
"total_available": self.total_available,
|
||||
"explanation": self.explanation,
|
||||
"subjects": (
|
||||
[subject.to_dict() for subject in self.subjects]
|
||||
if include_subjects
|
||||
else []
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PolicyImpactSubjectProvider(Protocol):
|
||||
provider_id: str
|
||||
supported_policy_families: tuple[str, ...]
|
||||
|
||||
def collect_policy_impact_subjects(
|
||||
self,
|
||||
session: object | None = None,
|
||||
*,
|
||||
request: PolicyImpactPopulationRequest,
|
||||
) -> PolicyImpactSubjectBatch: ...
|
||||
|
||||
|
||||
def policy_impact_subject_provider(
|
||||
registry: object | None,
|
||||
provider_id: str,
|
||||
) -> PolicyImpactSubjectProvider | None:
|
||||
clean_provider_id = provider_id.strip()
|
||||
if not clean_provider_id or registry is None:
|
||||
return None
|
||||
capability_name = f"{CAPABILITY_POLICY_IMPACT_SUBJECT_PREFIX}{clean_provider_id}"
|
||||
if (
|
||||
not hasattr(registry, "has_capability")
|
||||
or not registry.has_capability(capability_name)
|
||||
):
|
||||
return None
|
||||
capability = registry.capability(capability_name)
|
||||
if not isinstance(capability, PolicyImpactSubjectProvider):
|
||||
return None
|
||||
return capability
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignArchiveEncryptionRequest:
|
||||
"""Context required to resolve one Campaign archive-encryption ceiling.
|
||||
|
||||
The owning module supplies the stable Campaign and owner references. Policy
|
||||
owns hierarchy evaluation; Campaign owns archive configuration and evidence.
|
||||
"""
|
||||
|
||||
tenant_id: str
|
||||
campaign_id: str
|
||||
owner_type: Literal["user", "group"] | None = None
|
||||
owner_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignArchiveEncryptionDecision:
|
||||
allowed_password_encryption_methods: frozenset[CampaignArchiveEncryptionMethod]
|
||||
allowed_password_delivery_channels: frozenset[
|
||||
CampaignArchivePasswordDeliveryChannel
|
||||
]
|
||||
policy_hash: str
|
||||
source_path: tuple[PolicySourceStep, ...] = ()
|
||||
reason: str | None = None
|
||||
diagnostics: tuple[Mapping[str, Any], ...] = ()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"allowed_password_encryption_methods": sorted(
|
||||
self.allowed_password_encryption_methods
|
||||
),
|
||||
"allowed_password_delivery_channels": sorted(
|
||||
self.allowed_password_delivery_channels
|
||||
),
|
||||
"policy_hash": self.policy_hash,
|
||||
"source_path": [step.to_dict() for step in self.source_path],
|
||||
"reason": self.reason,
|
||||
"diagnostics": [dict(item) for item in self.diagnostics],
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CampaignArchiveEncryptionPolicy(Protocol):
|
||||
def resolve_campaign_archive_encryption(
|
||||
self,
|
||||
session: object | None = None,
|
||||
*,
|
||||
request: CampaignArchiveEncryptionRequest,
|
||||
) -> CampaignArchiveEncryptionDecision: ...
|
||||
|
||||
|
||||
def campaign_archive_encryption_policy(
|
||||
registry: object | None,
|
||||
) -> CampaignArchiveEncryptionPolicy | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not registry.has_capability(CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION)
|
||||
):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION)
|
||||
return (
|
||||
capability
|
||||
if isinstance(capability, CampaignArchiveEncryptionPolicy)
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FunctionAssignmentGovernanceRequest:
|
||||
tenant_id: str
|
||||
@@ -196,6 +421,20 @@ class FunctionAssignmentGovernanceRequest:
|
||||
context: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FunctionAssignmentEscalationRule:
|
||||
step: FunctionAssignmentReviewStep
|
||||
target_function_id: str
|
||||
timeout_hours: int
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"step": self.step,
|
||||
"target_function_id": self.target_function_id,
|
||||
"timeout_hours": self.timeout_hours,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FunctionAssignmentGovernanceDecision:
|
||||
allowed: bool
|
||||
@@ -208,6 +447,10 @@ class FunctionAssignmentGovernanceDecision:
|
||||
separation_of_duties: bool = True
|
||||
quorum: int = 1
|
||||
maximum_validity_days: int | None = None
|
||||
delegation_allowed: bool = False
|
||||
maximum_delegation_depth: int = 0
|
||||
maximum_delegated_validity_days: int | None = None
|
||||
escalation_rules: tuple[FunctionAssignmentEscalationRule, ...] = ()
|
||||
request_expiry_hours: int = 336
|
||||
source_path: tuple[PolicySourceStep, ...] = ()
|
||||
requirements: tuple[str, ...] = ()
|
||||
@@ -225,12 +468,24 @@ class FunctionAssignmentGovernanceDecision:
|
||||
"separation_of_duties": self.separation_of_duties,
|
||||
"quorum": self.quorum,
|
||||
"maximum_validity_days": self.maximum_validity_days,
|
||||
"delegation_allowed": self.delegation_allowed,
|
||||
"maximum_delegation_depth": self.maximum_delegation_depth,
|
||||
"maximum_delegated_validity_days": (
|
||||
self.maximum_delegated_validity_days
|
||||
),
|
||||
"escalation_rules": [rule.to_dict() for rule in self.escalation_rules],
|
||||
"request_expiry_hours": self.request_expiry_hours,
|
||||
"source_path": [step.to_dict() for step in self.source_path],
|
||||
"requirements": list(self.requirements),
|
||||
"details": dict(self.details),
|
||||
}
|
||||
|
||||
def escalation_rule(
|
||||
self,
|
||||
step: FunctionAssignmentReviewStep,
|
||||
) -> FunctionAssignmentEscalationRule | None:
|
||||
return next((rule for rule in self.escalation_rules if rule.step == step), None)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class FunctionAssignmentGovernancePolicy(Protocol):
|
||||
|
||||
@@ -129,6 +129,16 @@ class PollParticipationContextRef:
|
||||
response: PollGovernedResponseRef | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollPublicInvitationRef:
|
||||
"""Non-sensitive routing identity for one valid governed invitation."""
|
||||
|
||||
invitation_id: str
|
||||
tenant_id: str
|
||||
poll_id: str
|
||||
gateway: PollResponseGatewayRef
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PollParticipationGatewayProvider(Protocol):
|
||||
def create_governed_invitation(
|
||||
@@ -156,6 +166,17 @@ class PollParticipationGatewayProvider(Protocol):
|
||||
|
||||
...
|
||||
|
||||
def resolve_public_invitation(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
token: str,
|
||||
gateway: PollResponseGatewayRef,
|
||||
) -> PollPublicInvitationRef:
|
||||
"""Resolve tenant routing without disclosing participant details."""
|
||||
|
||||
...
|
||||
|
||||
def submit_governed_response(
|
||||
self,
|
||||
session: object,
|
||||
@@ -266,6 +287,7 @@ __all__ = [
|
||||
"PollParticipationContextRef",
|
||||
"PollParticipationGatewayProvider",
|
||||
"PollParticipationPolicy",
|
||||
"PollPublicInvitationRef",
|
||||
"PollResponseGatewayRef",
|
||||
"participation_token_fingerprint",
|
||||
"poll_participation_gateway_provider",
|
||||
|
||||
@@ -15,6 +15,7 @@ CAPABILITY_POSTBOX_MESSAGES = "postbox.messages"
|
||||
CAPABILITY_POSTBOX_DELIVERY = "postbox.delivery"
|
||||
CAPABILITY_POSTBOX_EVIDENCE = "postbox.evidence"
|
||||
CAPABILITY_POSTBOX_ROUTING = "postbox.routing"
|
||||
CAPABILITY_POSTBOX_PORTAL = "postbox.portal_projection"
|
||||
|
||||
PostboxAction = Literal[
|
||||
"discover",
|
||||
@@ -171,6 +172,11 @@ class PostboxDirectoryEntryRef:
|
||||
template_revision_id: str | None = None
|
||||
holder_count: int = 0
|
||||
vacant: bool = True
|
||||
encryption_profile: str = "plaintext_v1"
|
||||
key_epoch: int = 1
|
||||
encryption_vault_id: str | None = None
|
||||
protection_policy: Mapping[str, object] = field(default_factory=dict)
|
||||
grouping_policy: Mapping[str, object] = field(default_factory=dict)
|
||||
access: PostboxAccessDecisionRef | None = None
|
||||
resource_revision: int = 1
|
||||
etag: str | None = None
|
||||
@@ -259,6 +265,9 @@ class PostboxMessageAuthoringRequest:
|
||||
idempotency_key: str
|
||||
subject: str
|
||||
body_text: str | None = None
|
||||
ciphertext_ref: str | None = None
|
||||
signed_manifest_ref: str | None = None
|
||||
wrapped_keys: tuple[PostboxWrappedKeyRef, ...] = ()
|
||||
classification: str = "internal"
|
||||
participants: tuple[PostboxParticipantRef, ...] = ()
|
||||
attachments: tuple[PostboxAttachmentRef, ...] = ()
|
||||
@@ -300,6 +309,7 @@ class PostboxDeliveryRequest:
|
||||
body_text: str | None = None
|
||||
sender_label: str | None = None
|
||||
classification: str = "internal"
|
||||
action_required: bool = False
|
||||
participants: tuple[PostboxParticipantRef, ...] = ()
|
||||
attachments: tuple[PostboxAttachmentRef, ...] = ()
|
||||
expires_at: datetime | None = None
|
||||
@@ -323,6 +333,14 @@ class PostboxDeliveryResult:
|
||||
evidence: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxPortalEntryRef:
|
||||
postbox: PostboxDirectoryEntryRef
|
||||
unread_count: int = 0
|
||||
latest_message_at: datetime | None = None
|
||||
route_path: str = "/postbox"
|
||||
|
||||
|
||||
class PostboxDeliveryRejected(RuntimeError):
|
||||
"""A delivery was rejected before the provider accepted any effect."""
|
||||
|
||||
@@ -494,6 +512,31 @@ class PostboxRoutingProvider(Protocol):
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
def reconcile_notification_lifecycle(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> Mapping[str, object]:
|
||||
"""Reconcile assignment-derived Postbox notification facts."""
|
||||
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PostboxPortalProjectionProvider(Protocol):
|
||||
"""Project portal-enabled Postboxes without transferring access ownership."""
|
||||
|
||||
def list_portal_entries(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
limit: int = 100,
|
||||
) -> Sequence[PostboxPortalEntryRef]: ...
|
||||
|
||||
|
||||
def _postbox_provider(
|
||||
registry: object | None,
|
||||
@@ -573,3 +616,18 @@ def postbox_routing_provider(
|
||||
provider_type=PostboxRoutingProvider,
|
||||
)
|
||||
return provider if isinstance(provider, PostboxRoutingProvider) else None
|
||||
|
||||
|
||||
def postbox_portal_projection_provider(
|
||||
registry: object | None,
|
||||
) -> PostboxPortalProjectionProvider | None:
|
||||
provider = _postbox_provider(
|
||||
registry,
|
||||
capability_name=CAPABILITY_POSTBOX_PORTAL,
|
||||
provider_type=PostboxPortalProjectionProvider,
|
||||
)
|
||||
return (
|
||||
provider
|
||||
if isinstance(provider, PostboxPortalProjectionProvider)
|
||||
else None
|
||||
)
|
||||
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
"""Pure principal attribution mechanics, not authorization or tenant resolution.
|
||||
|
||||
The two existing contracts intentionally differ in precedence and whitespace.
|
||||
Callers retain their own service-account, scope and resource-access decisions.
|
||||
"""
|
||||
|
||||
|
||||
def principal_actor_ids(principal: object) -> tuple[str, ...]:
|
||||
"""Account-first legacy IDs, unique in encounter order; retain nonblank text."""
|
||||
user = getattr(principal, "user", None)
|
||||
return tuple(
|
||||
dict.fromkeys(
|
||||
str(value)
|
||||
for value in (
|
||||
getattr(principal, "account_id", None),
|
||||
getattr(principal, "identity_id", None),
|
||||
getattr(principal, "membership_id", None),
|
||||
getattr(user, "id", None),
|
||||
)
|
||||
if str(value or "").strip()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def principal_user_first_actor(principal: object) -> str | None:
|
||||
"""First nonblank user/account/identity/membership ID, with trimmed text."""
|
||||
user = getattr(principal, "user", None)
|
||||
for value in (
|
||||
getattr(user, "id", None),
|
||||
getattr(principal, "account_id", None),
|
||||
getattr(principal, "identity_id", None),
|
||||
getattr(principal, "membership_id", None),
|
||||
):
|
||||
candidate = str(value or "").strip()
|
||||
if candidate:
|
||||
return candidate
|
||||
return None
|
||||
@@ -0,0 +1,369 @@
|
||||
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_RECORDS_FILING = "records.filing"
|
||||
CAPABILITY_RECORD_SOURCE_PREFIX = "records.source."
|
||||
CAPABILITY_RECORD_ARCHIVE_PREFIX = "records.archive."
|
||||
|
||||
RecordSourceAuthority = Literal[
|
||||
"native_authoritative",
|
||||
"external_authoritative",
|
||||
"external_mirror",
|
||||
"governed_sync",
|
||||
"governance_overlay",
|
||||
"linked_reference",
|
||||
]
|
||||
RecordArchiveOutcome = Literal["accepted", "rejected", "outcome_unknown"]
|
||||
|
||||
_RECORD_SOURCE_AUTHORITIES = {
|
||||
"native_authoritative",
|
||||
"external_authoritative",
|
||||
"external_mirror",
|
||||
"governed_sync",
|
||||
"governance_overlay",
|
||||
"linked_reference",
|
||||
}
|
||||
_RECORD_ARCHIVE_OUTCOMES = {"accepted", "rejected", "outcome_unknown"}
|
||||
|
||||
|
||||
class RecordContractError(ValueError):
|
||||
"""Stable error for provider-neutral record filing operations."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordSourceLocator:
|
||||
"""Exact source revision requested for filing into a record."""
|
||||
|
||||
tenant_id: str
|
||||
source_module: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
source_revision: str
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_text_fields(
|
||||
self,
|
||||
"tenant_id",
|
||||
"source_module",
|
||||
"resource_type",
|
||||
"resource_id",
|
||||
"source_revision",
|
||||
)
|
||||
if len(self.resource_id) > 500 or len(self.source_revision) > 255:
|
||||
raise RecordContractError("Record source identity is too long.")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordSourceReference:
|
||||
"""Provider-resolved immutable source metadata safe to preserve in Records."""
|
||||
|
||||
locator: RecordSourceLocator
|
||||
label: str
|
||||
authority_mode: RecordSourceAuthority = "linked_reference"
|
||||
content_sha256: str | None = None
|
||||
content_type: str | None = None
|
||||
size_bytes: int | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_to: datetime | None = None
|
||||
recorded_at: datetime | None = None
|
||||
launch_url: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.label.strip():
|
||||
raise RecordContractError("Record source references require a label.")
|
||||
if len(self.label) > 500:
|
||||
raise RecordContractError(
|
||||
"Record source labels are limited to 500 characters."
|
||||
)
|
||||
if self.size_bytes is not None and self.size_bytes < 0:
|
||||
raise RecordContractError("Record source sizes cannot be negative.")
|
||||
if self.content_sha256 is not None:
|
||||
digest = self.content_sha256.removeprefix("sha256:")
|
||||
if len(digest) != 64 or any(
|
||||
character not in "0123456789abcdefABCDEF" for character in digest
|
||||
):
|
||||
raise RecordContractError(
|
||||
"Record source SHA-256 digests must be hexadecimal."
|
||||
)
|
||||
if self.valid_from and self.valid_to and self.valid_to <= self.valid_from:
|
||||
raise RecordContractError(
|
||||
"Record source valid_to must be after valid_from."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordFilingRequest:
|
||||
tenant_id: str
|
||||
record_id: str
|
||||
source: RecordSourceLocator
|
||||
purpose: str
|
||||
filing_reason: str
|
||||
idempotency_key: str
|
||||
volume_id: str | None = None
|
||||
relationship: str = "contains"
|
||||
institutional_context: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_text_fields(
|
||||
self,
|
||||
"tenant_id",
|
||||
"record_id",
|
||||
"purpose",
|
||||
"filing_reason",
|
||||
"idempotency_key",
|
||||
"relationship",
|
||||
)
|
||||
if self.source.tenant_id != self.tenant_id:
|
||||
raise RecordContractError("Record filing cannot cross tenants.")
|
||||
if len(self.purpose) > 255 or len(self.filing_reason) > 2_000:
|
||||
raise RecordContractError("Record filing purpose or reason is too long.")
|
||||
if len(self.idempotency_key) > 255:
|
||||
raise RecordContractError(
|
||||
"Record filing idempotency keys are limited to 255 characters."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordFilingResult:
|
||||
record_id: str
|
||||
item_id: str
|
||||
sequence: int
|
||||
source: RecordSourceReference
|
||||
filed_at: datetime
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordTransferPackage:
|
||||
"""Exact, digest-bound package prepared by Records for one provider profile."""
|
||||
|
||||
tenant_id: str
|
||||
package_id: str
|
||||
record_id: str
|
||||
record_revision: int
|
||||
profile: str
|
||||
manifest_sha256: str
|
||||
manifest: Mapping[str, object]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_text_fields(
|
||||
self,
|
||||
"tenant_id",
|
||||
"package_id",
|
||||
"record_id",
|
||||
"profile",
|
||||
"manifest_sha256",
|
||||
)
|
||||
if self.record_revision < 1:
|
||||
raise RecordContractError("Record transfer revisions must be positive.")
|
||||
_require_sha256(self.manifest_sha256, "Record transfer manifest")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordArchiveProviderState:
|
||||
provider_id: str
|
||||
label: str
|
||||
profiles: tuple[str, ...]
|
||||
authority_modes: tuple[RecordSourceAuthority, ...]
|
||||
healthy: bool
|
||||
checked_at: datetime
|
||||
last_success_at: datetime | None = None
|
||||
freshness_seconds: int | None = None
|
||||
limitations: tuple[str, ...] = ()
|
||||
simulated: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_text_fields(self, "provider_id", "label")
|
||||
if not self.profiles or any(not item.strip() for item in self.profiles):
|
||||
raise RecordContractError(
|
||||
"Record archive providers require at least one profile."
|
||||
)
|
||||
if not self.authority_modes:
|
||||
raise RecordContractError(
|
||||
"Record archive providers require an authority mode."
|
||||
)
|
||||
if any(mode not in _RECORD_SOURCE_AUTHORITIES for mode in self.authority_modes):
|
||||
raise RecordContractError(
|
||||
"Record archive providers declared an invalid authority mode."
|
||||
)
|
||||
if self.freshness_seconds is not None and self.freshness_seconds < 0:
|
||||
raise RecordContractError(
|
||||
"Record archive provider freshness cannot be negative."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordArchiveTransferRequest:
|
||||
package: RecordTransferPackage
|
||||
purpose: str
|
||||
idempotency_key: str
|
||||
institutional_context: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_text_fields(self, "purpose", "idempotency_key")
|
||||
if len(self.purpose) > 255 or len(self.idempotency_key) > 255:
|
||||
raise RecordContractError(
|
||||
"Record archive purpose or idempotency key is too long."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordArchiveReceipt:
|
||||
provider_id: str
|
||||
package_id: str
|
||||
outcome: RecordArchiveOutcome
|
||||
observed_at: datetime
|
||||
receipt_sha256: str
|
||||
external_reference: str | None = None
|
||||
retry_safe: bool = False
|
||||
simulated: bool = False
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_text_fields(
|
||||
self,
|
||||
"provider_id",
|
||||
"package_id",
|
||||
"outcome",
|
||||
"receipt_sha256",
|
||||
)
|
||||
_require_sha256(self.receipt_sha256, "Record archive receipt")
|
||||
if self.outcome not in _RECORD_ARCHIVE_OUTCOMES:
|
||||
raise RecordContractError("Record archive receipt outcome is invalid.")
|
||||
if self.outcome == "outcome_unknown" and self.retry_safe:
|
||||
raise RecordContractError(
|
||||
"Unknown archive outcomes cannot be declared retry-safe."
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RecordSourceProvider(Protocol):
|
||||
provider_id: str
|
||||
|
||||
def resource_types(self) -> Sequence[str]: ...
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
locator: RecordSourceLocator,
|
||||
purpose: str,
|
||||
) -> RecordSourceReference:
|
||||
"""Resolve one currently authorized, exact source revision."""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RecordFilingService(Protocol):
|
||||
def file(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: RecordFilingRequest,
|
||||
) -> RecordFilingResult: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RecordArchiveProvider(Protocol):
|
||||
provider_id: str
|
||||
|
||||
def state(self) -> RecordArchiveProviderState: ...
|
||||
|
||||
def dispatch(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: RecordArchiveTransferRequest,
|
||||
) -> RecordArchiveReceipt:
|
||||
"""Dispatch one prepared package without retrying an unknown outcome."""
|
||||
|
||||
|
||||
def record_source_capability(source_module: str) -> str:
|
||||
normalized = _capability_suffix(source_module, "source module")
|
||||
return f"{CAPABILITY_RECORD_SOURCE_PREFIX}{normalized}"
|
||||
|
||||
|
||||
def record_archive_capability(provider_id: str) -> str:
|
||||
normalized = _capability_suffix(provider_id, "archive provider")
|
||||
return f"{CAPABILITY_RECORD_ARCHIVE_PREFIX}{normalized}"
|
||||
|
||||
|
||||
def record_source_capabilities(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 str(name).startswith(CAPABILITY_RECORD_SOURCE_PREFIX)
|
||||
)
|
||||
|
||||
|
||||
def record_archive_capabilities(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 str(name).startswith(CAPABILITY_RECORD_ARCHIVE_PREFIX)
|
||||
)
|
||||
|
||||
|
||||
def _require_text_fields(value: object, *field_names: str) -> None:
|
||||
for field_name in field_names:
|
||||
if not str(getattr(value, field_name, "") or "").strip():
|
||||
raise RecordContractError(
|
||||
f"Record contract field {field_name} is required."
|
||||
)
|
||||
|
||||
|
||||
def _capability_suffix(value: str, label: str) -> str:
|
||||
normalized = value.strip().lower()
|
||||
if not normalized or any(
|
||||
character not in "abcdefghijklmnopqrstuvwxyz0123456789_-"
|
||||
for character in normalized
|
||||
):
|
||||
raise RecordContractError(f"Record {label} identifiers are invalid.")
|
||||
return normalized
|
||||
|
||||
|
||||
def _require_sha256(value: str, label: str) -> None:
|
||||
digest = value.removeprefix("sha256:")
|
||||
if len(digest) != 64 or any(
|
||||
character not in "0123456789abcdefABCDEF" for character in digest
|
||||
):
|
||||
raise RecordContractError(f"{label} SHA-256 must be hexadecimal.")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_RECORD_ARCHIVE_PREFIX",
|
||||
"CAPABILITY_RECORDS_FILING",
|
||||
"CAPABILITY_RECORD_SOURCE_PREFIX",
|
||||
"RecordArchiveOutcome",
|
||||
"RecordArchiveProvider",
|
||||
"RecordArchiveProviderState",
|
||||
"RecordArchiveReceipt",
|
||||
"RecordArchiveTransferRequest",
|
||||
"RecordContractError",
|
||||
"RecordFilingRequest",
|
||||
"RecordFilingResult",
|
||||
"RecordFilingService",
|
||||
"RecordSourceAuthority",
|
||||
"RecordSourceLocator",
|
||||
"RecordSourceProvider",
|
||||
"RecordSourceReference",
|
||||
"RecordTransferPackage",
|
||||
"record_archive_capabilities",
|
||||
"record_archive_capability",
|
||||
"record_source_capabilities",
|
||||
"record_source_capability",
|
||||
]
|
||||
@@ -41,6 +41,7 @@ class RecoveryStatus(StrEnum):
|
||||
PREPARED = "prepared"
|
||||
RUNNING = "running"
|
||||
SUCCEEDED = "succeeded"
|
||||
REJECTED = "rejected"
|
||||
FAILED = "failed"
|
||||
OUTCOME_UNKNOWN = "outcome_unknown"
|
||||
RECOVERY_REQUIRED = "recovery_required"
|
||||
@@ -52,6 +53,7 @@ class RecoveryStatus(StrEnum):
|
||||
TERMINAL_RECOVERY_STATUSES = frozenset(
|
||||
{
|
||||
RecoveryStatus.SUCCEEDED.value,
|
||||
RecoveryStatus.REJECTED.value,
|
||||
RecoveryStatus.FAILED.value,
|
||||
RecoveryStatus.RECOVERED.value,
|
||||
RecoveryStatus.MANUAL_INTERVENTION.value,
|
||||
@@ -69,6 +71,7 @@ _TRANSITIONS: dict[str, frozenset[str]] = {
|
||||
RecoveryStatus.RUNNING.value: frozenset(
|
||||
{
|
||||
RecoveryStatus.SUCCEEDED.value,
|
||||
RecoveryStatus.REJECTED.value,
|
||||
RecoveryStatus.FAILED.value,
|
||||
RecoveryStatus.OUTCOME_UNKNOWN.value,
|
||||
RecoveryStatus.RECOVERY_REQUIRED.value,
|
||||
@@ -431,7 +434,11 @@ def transition_recovery_operation(
|
||||
elif status == RecoveryStatus.RECOVERED:
|
||||
locked.recovered_at = observed_at
|
||||
locked.completed_at = observed_at
|
||||
elif status in {RecoveryStatus.FAILED, RecoveryStatus.MANUAL_INTERVENTION}:
|
||||
elif status in {
|
||||
RecoveryStatus.REJECTED,
|
||||
RecoveryStatus.FAILED,
|
||||
RecoveryStatus.MANUAL_INTERVENTION,
|
||||
}:
|
||||
locked.completed_at = observed_at
|
||||
session.add(locked)
|
||||
record_recovery_checkpoint(
|
||||
@@ -591,7 +598,11 @@ def _validate_transition_evidence(
|
||||
evidence: dict[str, Any],
|
||||
failure_summary: str | None,
|
||||
) -> None:
|
||||
if status in {RecoveryStatus.SUCCEEDED, RecoveryStatus.RECOVERED}:
|
||||
if status in {
|
||||
RecoveryStatus.SUCCEEDED,
|
||||
RecoveryStatus.REJECTED,
|
||||
RecoveryStatus.RECOVERED,
|
||||
}:
|
||||
checks = evidence.get("checks")
|
||||
if (
|
||||
evidence.get("verified") is not True
|
||||
@@ -602,7 +613,7 @@ def _validate_transition_evidence(
|
||||
or not checks
|
||||
):
|
||||
raise RecoveryGuaranteeError(
|
||||
"Successful recovery transitions require verified evidence and check results"
|
||||
"Verified terminal transitions require verified evidence and check results"
|
||||
)
|
||||
if status == RecoveryStatus.MANUAL_INTERVENTION and not failure_summary:
|
||||
raise RecoveryGuaranteeError(
|
||||
|
||||
@@ -105,6 +105,121 @@ class DurableRecoveryOperation:
|
||||
session.commit()
|
||||
self.closed = True
|
||||
|
||||
def commit_atomic_success(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
evidence: dict[str, Any],
|
||||
) -> None:
|
||||
"""Commit domain writes and verified success in one DB transaction."""
|
||||
|
||||
self._commit_terminal(
|
||||
session,
|
||||
status=RecoveryStatus.SUCCEEDED,
|
||||
summary="Operation effects and authoritative state were verified",
|
||||
kind="verified-success",
|
||||
evidence=evidence,
|
||||
require_atomic_mode=True,
|
||||
)
|
||||
|
||||
def commit_verified_success(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
evidence: dict[str, Any],
|
||||
) -> None:
|
||||
"""Commit a verified success projection and checkpoint together.
|
||||
|
||||
Non-atomic operations use this only after their external effect has a
|
||||
conclusive provider result. It does not make that effect atomic; it
|
||||
prevents local success from outrunning its durable verification.
|
||||
"""
|
||||
|
||||
self._commit_terminal(
|
||||
session,
|
||||
status=RecoveryStatus.SUCCEEDED,
|
||||
summary="Operation effects and authoritative state were verified",
|
||||
kind="verified-success",
|
||||
evidence=evidence,
|
||||
require_atomic_mode=False,
|
||||
)
|
||||
|
||||
def commit_atomic_failure(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
summary: str,
|
||||
evidence: dict[str, Any],
|
||||
) -> None:
|
||||
"""Commit domain failure evidence and the terminal state atomically."""
|
||||
|
||||
self._commit_terminal(
|
||||
session,
|
||||
status=RecoveryStatus.FAILED,
|
||||
summary=summary,
|
||||
kind="verified-failure",
|
||||
evidence=evidence,
|
||||
require_atomic_mode=True,
|
||||
)
|
||||
|
||||
def commit_atomic_rejection(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
summary: str,
|
||||
evidence: dict[str, Any],
|
||||
) -> None:
|
||||
"""Commit a definitive rejection and its domain evidence atomically."""
|
||||
|
||||
self._commit_terminal(
|
||||
session,
|
||||
status=RecoveryStatus.REJECTED,
|
||||
summary=summary,
|
||||
kind="verified-rejection",
|
||||
evidence=evidence,
|
||||
require_atomic_mode=True,
|
||||
)
|
||||
|
||||
def fail(self, *, summary: str, evidence: dict[str, Any]) -> None:
|
||||
"""Finish a verified, ordinary failure that needs no recovery."""
|
||||
|
||||
with self.session_factory() as session:
|
||||
operation, claim = self._locked_and_renewed(session)
|
||||
transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.FAILED,
|
||||
kind="verified-failure",
|
||||
summary=summary,
|
||||
evidence=evidence,
|
||||
failure_summary=summary,
|
||||
lease_claim=claim,
|
||||
)
|
||||
self._verify_chain(session)
|
||||
release_lease(session, claim)
|
||||
session.commit()
|
||||
self.closed = True
|
||||
|
||||
def reject(self, *, summary: str, evidence: dict[str, Any]) -> None:
|
||||
"""Finish an operation with a verified definitive rejection."""
|
||||
|
||||
with self.session_factory() as session:
|
||||
operation, claim = self._locked_and_renewed(session)
|
||||
transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.REJECTED,
|
||||
kind="verified-rejection",
|
||||
summary=summary,
|
||||
evidence=evidence,
|
||||
failure_summary=summary,
|
||||
lease_claim=claim,
|
||||
)
|
||||
self._verify_chain(session)
|
||||
release_lease(session, claim)
|
||||
session.commit()
|
||||
self.closed = True
|
||||
|
||||
def compensate(
|
||||
self,
|
||||
*,
|
||||
@@ -179,6 +294,58 @@ class DurableRecoveryOperation:
|
||||
session.commit()
|
||||
self.closed = True
|
||||
|
||||
def resolve_unknown(
|
||||
self,
|
||||
*,
|
||||
effect_occurred: bool,
|
||||
evidence: dict[str, Any],
|
||||
summary: str,
|
||||
) -> None:
|
||||
"""Resolve an externally verified operation with an unknown outcome.
|
||||
|
||||
A confirmed provider effect is a verified success. A confirmed absence
|
||||
of the effect is recorded as forward recovery: the declared invariant
|
||||
is restored and the original effect may be attempted again under a new
|
||||
idempotency key.
|
||||
"""
|
||||
|
||||
with self.session_factory() as session:
|
||||
try:
|
||||
self._transition_unknown_resolution(
|
||||
session,
|
||||
effect_occurred=effect_occurred,
|
||||
evidence=evidence,
|
||||
summary=summary,
|
||||
)
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self.closed = True
|
||||
|
||||
def commit_unknown_resolution(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
effect_occurred: bool,
|
||||
evidence: dict[str, Any],
|
||||
summary: str,
|
||||
) -> None:
|
||||
"""Commit an operator reconciliation and its domain projection together."""
|
||||
|
||||
try:
|
||||
self._transition_unknown_resolution(
|
||||
session,
|
||||
effect_occurred=effect_occurred,
|
||||
evidence=evidence,
|
||||
summary=summary,
|
||||
)
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self.closed = True
|
||||
|
||||
def release_unresolved(self) -> None:
|
||||
"""Release authority after a process-local exception.
|
||||
|
||||
@@ -218,10 +385,111 @@ class DurableRecoveryOperation:
|
||||
select(RecoveryOperation)
|
||||
.where(RecoveryOperation.id == self.operation_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
).scalar_one()
|
||||
self.lease_claim = claim
|
||||
return operation, claim
|
||||
|
||||
def _commit_terminal(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
status: RecoveryStatus,
|
||||
summary: str,
|
||||
kind: str,
|
||||
evidence: dict[str, Any],
|
||||
require_atomic_mode: bool,
|
||||
) -> None:
|
||||
if status not in {
|
||||
RecoveryStatus.SUCCEEDED,
|
||||
RecoveryStatus.FAILED,
|
||||
RecoveryStatus.REJECTED,
|
||||
}:
|
||||
raise ValueError("Unsupported terminal recovery status")
|
||||
try:
|
||||
operation, claim = self._locked_and_renewed(session)
|
||||
if (
|
||||
require_atomic_mode
|
||||
and operation.mode != RecoveryMode.ATOMIC.value
|
||||
):
|
||||
raise RecoveryGuaranteeError(
|
||||
"Atomic terminal commits require an atomic recovery plan"
|
||||
)
|
||||
transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=status,
|
||||
kind=kind,
|
||||
summary=summary,
|
||||
evidence=evidence,
|
||||
failure_summary=(
|
||||
summary
|
||||
if status in {RecoveryStatus.FAILED, RecoveryStatus.REJECTED}
|
||||
else None
|
||||
),
|
||||
lease_claim=claim,
|
||||
)
|
||||
self._verify_chain(session)
|
||||
release_lease(session, claim)
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self.closed = True
|
||||
|
||||
def _transition_unknown_resolution(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
effect_occurred: bool,
|
||||
evidence: dict[str, Any],
|
||||
summary: str,
|
||||
) -> None:
|
||||
operation, claim = self._locked_and_renewed(session)
|
||||
if operation.status != RecoveryStatus.OUTCOME_UNKNOWN.value:
|
||||
raise RecoveryOperationStateConflict(operation.id, operation.status)
|
||||
if effect_occurred:
|
||||
transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.SUCCEEDED,
|
||||
kind="unknown-outcome-verified-success",
|
||||
summary=summary,
|
||||
evidence=evidence,
|
||||
lease_claim=claim,
|
||||
)
|
||||
else:
|
||||
operation = transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.RECOVERY_REQUIRED,
|
||||
kind="unknown-outcome-recovery-required",
|
||||
summary=summary,
|
||||
evidence=evidence,
|
||||
failure_summary="The external effect was verified absent",
|
||||
lease_claim=claim,
|
||||
)
|
||||
operation = transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.RECOVERING,
|
||||
kind="unknown-outcome-recovery-started",
|
||||
summary="Recording the verified absence of the external effect",
|
||||
evidence={"effect_occurred": False},
|
||||
lease_claim=claim,
|
||||
)
|
||||
transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.RECOVERED,
|
||||
kind="unknown-outcome-verified-absent",
|
||||
summary=summary,
|
||||
evidence=evidence,
|
||||
lease_claim=claim,
|
||||
)
|
||||
self._verify_chain(session)
|
||||
release_lease(session, claim)
|
||||
|
||||
def _verify_chain(self, session: Session) -> None:
|
||||
if not verify_recovery_evidence_chain(session, self.operation_id):
|
||||
raise RecoveryGuaranteeError(
|
||||
@@ -244,6 +512,7 @@ def begin_durable_recovery_operation(
|
||||
resource_type: str | None = None,
|
||||
resource_id: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
block_unresolved_resource: bool = False,
|
||||
) -> DurableRecoveryStart:
|
||||
if lease_ttl_seconds < 1:
|
||||
raise ValueError("Recovery lease TTL must be at least one second")
|
||||
@@ -271,6 +540,31 @@ def begin_durable_recovery_operation(
|
||||
RecoveryOperation.idempotency_key == idempotency_key,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if block_unresolved_resource:
|
||||
blocking = session.execute(
|
||||
select(RecoveryOperation).where(
|
||||
RecoveryOperation.installation_id == identity.installation_id,
|
||||
RecoveryOperation.lease_resource_key == lease_resource_key,
|
||||
RecoveryOperation.status.not_in(
|
||||
(
|
||||
RecoveryStatus.SUCCEEDED.value,
|
||||
RecoveryStatus.REJECTED.value,
|
||||
RecoveryStatus.FAILED.value,
|
||||
RecoveryStatus.RECOVERED.value,
|
||||
RecoveryStatus.MANUAL_INTERVENTION.value,
|
||||
)
|
||||
),
|
||||
)
|
||||
).scalars().first()
|
||||
if blocking is not None and (
|
||||
existing is None or blocking.id != existing.id
|
||||
):
|
||||
release_lease(session, claim)
|
||||
session.commit()
|
||||
raise RecoveryOperationStateConflict(
|
||||
blocking.id,
|
||||
blocking.status,
|
||||
)
|
||||
operation = plan_recovery_operation(
|
||||
session,
|
||||
installation_id=identity.installation_id,
|
||||
@@ -339,6 +633,7 @@ def claim_durable_recovery_operation(
|
||||
raise RecoveryGuaranteeError("Recovery operation was not found")
|
||||
if candidate.status in {
|
||||
RecoveryStatus.SUCCEEDED.value,
|
||||
RecoveryStatus.REJECTED.value,
|
||||
RecoveryStatus.FAILED.value,
|
||||
RecoveryStatus.RECOVERED.value,
|
||||
RecoveryStatus.MANUAL_INTERVENTION.value,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user