Compare commits

...
50 Commits
Author SHA1 Message Date
zemion 982ef636b8 Release v0.1.15
Module Package Release / publish-packages (push) Successful in 13s
2026-08-04 15:22:52 +02:00
zemion 9aad49f16d Make package publication retries hash-safe 2026-08-04 14:19:04 +02:00
zemion 2b5c14385d Add Voting provider assurance contract 2026-08-04 14:01:05 +02:00
zemion bca3e46293 Publish npm artifacts from explicit local paths 2026-08-04 14:00:57 +02:00
zemion f09d2bf9df fix(ci): normalize historical package refs 2026-08-04 13:34:00 +02:00
zemion 702421be48 fix(ci): rely on protected release boundary 2026-08-04 13:32:28 +02:00
zemion bb471df21c fix(ci): bind Gitea action context explicitly 2026-08-04 13:28:28 +02:00
zemion bfb0d7d7c9 Allow Dataflow datasets to pin published runs 2026-08-04 12:48:37 +02:00
zemion 1974bf1a2b Define bounded tabular source contracts 2026-08-04 12:05:09 +02:00
zemion 0c9bf6758c Add secure password generator 2026-08-04 11:07:55 +02:00
zemion 25da7d49a9 Add controlled first-admin enrollment 2026-08-04 10:07:10 +02:00
zemion 40c10089ab Enforce tenant module entitlements beyond requests 2026-08-04 09:29:36 +02:00
zemion d6e7c8b0b1 Add governed module and interface controls 2026-08-04 05:20:47 +02:00
zemion 5bc7d748f8 Add protected package release workflow 2026-08-04 04:14:03 +02:00
zemion 7117673ecc Add durable search event indexing contract 2026-08-04 03:03:15 +02:00
zemion 14351b0c94 Register trust and encryption WebUI modules 2026-08-04 01:27:58 +02:00
zemion ad57fad1ea Reject duplicate module migration revisions 2026-08-03 15:04:18 +02:00
zemion fa32cca03f Complete guided Core configuration patterns 2026-08-03 10:16:26 +02:00
zemion 2d0551a845 Explain disabled mail connection tests 2026-08-03 10:05:59 +02:00
zemion bb84122061 Expose object modification evidence 2026-08-03 09:23:15 +02:00
zemion b823a22b9b Link contextual guidance to Docs 2026-08-03 07:33:57 +02:00
zemion 70fc6da811 Localize shared blocker guidance 2026-08-03 07:22:16 +02:00
zemion 729b84d3af Fence and reconcile module lifecycle effects 2026-08-03 07:02:07 +02:00
zemion b962f6756e Extend action recovery reconciliation 2026-08-03 06:37:45 +02:00
zemion 79d00b84e3 Commit verified recovery projections atomically 2026-08-03 06:09:52 +02:00
zemion 842be5edb5 Commit atomic domain and recovery state together 2026-08-03 05:43:39 +02:00
zemion 7e59a7f2b3 Resolve unknown recovery outcomes from evidence 2026-08-03 05:00:08 +02:00
zemion 5bfbe9a887 Bridge legacy WebUI router imports 2026-08-03 03:59:08 +02:00
zemion 01f91154e0 Add durable external-effect runtime identity 2026-08-03 03:47:41 +02:00
zemion 6c2940aebc Refresh shared contract test fixtures 2026-08-03 03:07:59 +02:00
zemion 670693bde8 Support repository-root WebUI packages 2026-08-03 03:07:51 +02:00
zemion bca6a7c8aa Implement durable module recovery operations 2026-08-03 03:07:42 +02:00
zemion 21c1fa49b6 Add worker delivery acceptance probe 2026-08-03 00:20:51 +02:00
zemion 435b924fd9 Extend calendar invitation capability contract 2026-08-02 16:38:34 +02:00
zemion af5c6af0e7 Define connector runtime preview contract 2026-08-02 14:54:44 +02:00
zemion c6ef644842 Add typed IDM relationship contracts 2026-08-02 14:44:41 +02:00
zemion 5783d43547 Validate Campaign template contracts 2026-08-02 13:58:49 +02:00
zemion e4d2d10c7e Add template and generated artifact contracts 2026-08-02 12:37:58 +02:00
zemion fe62fd4644 Validate Campaign Distribution Lists contract 2026-08-02 11:53:36 +02:00
zemion 9ecdc6d713 Add contact point resolution contract 2026-08-02 06:20:18 +02:00
zemion ca35aad286 Add external calendar profile contract 2026-08-02 05:59:06 +02:00
zemion d6255f9f8f feat: harden multi-host runtime coordination 2026-08-02 05:30:11 +02:00
zemion b58c9c55cf feat: define governed report provider contract 2026-08-02 05:30:06 +02:00
zemion 3c4bcc28f1 refactor: consolidate shared catalog and API helpers 2026-08-02 05:30:02 +02:00
zemion 972c681650 feat: finalize encryption and voting provider contracts 2026-08-02 03:40:44 +02:00
zemion 2b4eb0151f Add governed institutional capability contracts 2026-08-01 20:57:25 +02:00
zemion 7192d32e65 feat: add institutional governance and recovery contracts 2026-08-01 17:46:54 +02:00
zemion b65b48832b Add cross-module operational and interaction contracts 2026-07-31 22:48:07 +02:00
zemion 4cb334c912 Add distribution audience contracts and module wiring 2026-07-31 20:59:49 +02:00
zemion 6ebb299d6c Add workflow baseline orchestration contracts 2026-07-31 19:39:59 +02:00
189 changed files with 29664 additions and 1129 deletions
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+2
View File
@@ -45,6 +45,8 @@ tools/checks/check-focused.sh
- Prefer `rg`, `sed`, and targeted tests over broad recursive scans or full builds.
- Avoid DataGrid changes unless explicitly requested; it is intentionally brittle and has known deferred work.
- Do not add module-to-module imports for optional integrations. Use core registry/capability/module metadata paths.
- Treat documentation as part of every behavior change. Update the owning module's manifest-driven `DocumentationTopic` contributions for each affected user and administrator workflow, setting, permission, limitation, and operational consequence. Feature modules own this content; `govoplan-docs` projects it and must not import feature internals.
- Keep a static user and administrator documentation baseline in every module manifest, even when richer configured-state topics come from `documentation_providers`. Run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after changing a manifest or module behavior.
- Treat Gitea issues as the canonical backlog and state log. Treat Gitea wiki pages as durable project context mirrored from repository and product docs. Use `docs/GITEA_ISSUES.md` for labels, templates, TODO import, wiki sync, and Codex issue updates.
- Do not keep generated WebUI test folders in git status; they should be ignored and removable.
- Do not start persistent dev servers unless the user asks.
+1 -1
View File
@@ -117,7 +117,7 @@ CI runs the `ci` profile in report-only mode and uploads `audit-reports/` as an
artifact. Once the baseline is clean, set `SECURITY_AUDIT_FAIL_ON_FINDINGS=1`
or pass `--strict` locally to turn findings into a failing gate.
`govoplan_core.devserver` enables the development bootstrap before loading settings. In dev, startup migrations create or upgrade the schema and the bootstrap creates the default development login if needed. Explicitly setting `DEV_BOOTSTRAP_ENABLED=false` disables this convenience. Production deployments should use migrations and managed database provisioning instead.
`govoplan_core.devserver` enables the development bootstrap before loading settings. In dev, startup migrations create or upgrade the schema and the bootstrap creates the default development login if needed. Explicitly setting `DEV_BOOTSTRAP_ENABLED=false` disables this convenience. Production deployments use the separate, expiring single-use flow exposed by `python -m govoplan_core.commands.first_admin`; it cannot enable or consume development bootstrap credentials.
To verify the effective runtime paths and bootstrap behavior without starting uvicorn, run the smoke mode:
@@ -0,0 +1,24 @@
"""development-track wrapper for runtime coordination and recovery."""
from __future__ import annotations
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
_path = (
Path(__file__).resolve().parents[1]
/ "versions"
/ "e14b8c2d6f90_runtime_coordination_recovery.py"
)
_spec = spec_from_file_location("govoplan_runtime_coordination_migration", _path)
if _spec is None or _spec.loader is None:
raise RuntimeError(f"Unable to load runtime coordination migration from {_path}")
_migration = module_from_spec(_spec)
_spec.loader.exec_module(_migration)
revision = _migration.revision
down_revision = _migration.down_revision
branch_labels = _migration.branch_labels
depends_on = _migration.depends_on
upgrade = _migration.upgrade
downgrade = _migration.downgrade
@@ -0,0 +1,23 @@
from __future__ import annotations
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
_path = (
Path(__file__).resolve().parents[1]
/ "versions"
/ "f25c9d3e7a01_first_admin_enrollment.py"
)
_spec = spec_from_file_location("govoplan_first_admin_enrollment_migration", _path)
if _spec is None or _spec.loader is None:
raise RuntimeError(f"Unable to load migration implementation from {_path}")
_module = module_from_spec(_spec)
_spec.loader.exec_module(_module)
revision = _module.revision
down_revision = _module.down_revision
branch_labels = _module.branch_labels
depends_on = _module.depends_on
upgrade = _module.upgrade
downgrade = _module.downgrade
+3
View File
@@ -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
@@ -0,0 +1,232 @@
"""add runtime coordination and recovery evidence
Revision ID: e14b8c2d6f90
Revises: d03a7b9c1e5f
Create Date: 2026-08-01 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e14b8c2d6f90"
down_revision = "d03a7b9c1e5f"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "core_runtime_nodes" not in tables:
op.create_table(
"core_runtime_nodes",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("installation_id", sa.String(length=100), nullable=False),
sa.Column("node_id", sa.String(length=200), nullable=False),
sa.Column("incarnation", sa.String(length=36), nullable=False),
sa.Column("role", sa.String(length=40), nullable=False),
sa.Column("software_version", sa.String(length=80), nullable=False),
sa.Column("composition_hash", sa.String(length=64), nullable=False),
sa.Column("queues", sa.JSON(), nullable=False),
sa.Column("state", sa.String(length=30), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_heartbeat_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("drain_requested_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("drain_reason", sa.String(length=500), nullable=True),
sa.Column("stopped_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_runtime_nodes")),
sa.UniqueConstraint(
"installation_id",
"node_id",
name="uq_core_runtime_node_installation_node",
),
)
for column in (
"installation_id",
"node_id",
"incarnation",
"role",
"composition_hash",
"state",
):
op.create_index(
op.f(f"ix_core_runtime_nodes_{column}"),
"core_runtime_nodes",
[column],
unique=False,
)
op.create_index(
"ix_core_runtime_nodes_installation_state_heartbeat",
"core_runtime_nodes",
["installation_id", "state", "last_heartbeat_at"],
unique=False,
)
if "core_distributed_leases" not in tables:
op.create_table(
"core_distributed_leases",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("installation_id", sa.String(length=100), nullable=False),
sa.Column("resource_key", sa.String(length=255), nullable=False),
sa.Column("holder_node_id", sa.String(length=200), nullable=True),
sa.Column("holder_incarnation", sa.String(length=36), nullable=True),
sa.Column("fencing_token", sa.BigInteger(), nullable=False),
sa.Column("acquired_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("renewed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_distributed_leases")),
sa.UniqueConstraint(
"installation_id",
"resource_key",
name="uq_core_distributed_lease_resource",
),
)
for column in (
"installation_id",
"resource_key",
"holder_node_id",
"holder_incarnation",
):
op.create_index(
op.f(f"ix_core_distributed_leases_{column}"),
"core_distributed_leases",
[column],
unique=False,
)
op.create_index(
"ix_core_distributed_leases_expiry",
"core_distributed_leases",
["installation_id", "expires_at"],
unique=False,
)
if "core_recovery_operations" not in tables:
op.create_table(
"core_recovery_operations",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("installation_id", sa.String(length=100), nullable=False),
sa.Column("module_id", sa.String(length=100), nullable=False),
sa.Column("operation_type", sa.String(length=100), nullable=False),
sa.Column("resource_type", sa.String(length=100), nullable=True),
sa.Column("resource_id", sa.String(length=255), nullable=True),
sa.Column("mode", sa.String(length=40), nullable=False),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("idempotency_key", sa.String(length=200), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("plan", sa.JSON(), nullable=False),
sa.Column("backup_reference", sa.String(length=1000), nullable=True),
sa.Column("approval_reference", sa.String(length=1000), nullable=True),
sa.Column("lease_resource_key", sa.String(length=255), nullable=True),
sa.Column("holder_node_id", sa.String(length=200), nullable=True),
sa.Column("holder_incarnation", sa.String(length=36), nullable=True),
sa.Column("fencing_token", sa.BigInteger(), nullable=True),
sa.Column("checkpoint_count", sa.Integer(), nullable=False),
sa.Column("evidence_head_sha256", sa.String(length=64), nullable=True),
sa.Column("failure_summary", sa.Text(), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("recovery_started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("recovered_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_recovery_operations")),
sa.UniqueConstraint(
"installation_id",
"module_id",
"idempotency_key",
name="uq_core_recovery_operation_idempotency",
),
)
for column in (
"installation_id",
"module_id",
"operation_type",
"resource_type",
"resource_id",
"mode",
"status",
):
op.create_index(
op.f(f"ix_core_recovery_operations_{column}"),
"core_recovery_operations",
[column],
unique=False,
)
op.create_index(
"ix_core_recovery_operations_status_updated",
"core_recovery_operations",
["installation_id", "status", "updated_at"],
unique=False,
)
op.create_index(
"ix_core_recovery_operations_resource",
"core_recovery_operations",
["module_id", "resource_type", "resource_id"],
unique=False,
)
if "core_recovery_checkpoints" not in tables:
op.create_table(
"core_recovery_checkpoints",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("operation_id", sa.String(length=36), nullable=False),
sa.Column("sequence", sa.Integer(), nullable=False),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("kind", sa.String(length=80), nullable=False),
sa.Column("summary", sa.Text(), nullable=False),
sa.Column("evidence", sa.JSON(), nullable=False),
sa.Column("previous_sha256", sa.String(length=64), nullable=True),
sa.Column("checkpoint_sha256", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["operation_id"],
["core_recovery_operations.id"],
name=op.f(
"fk_core_recovery_checkpoints_operation_id_core_recovery_operations"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_recovery_checkpoints")),
sa.UniqueConstraint(
"operation_id",
"sequence",
name="uq_core_recovery_checkpoint_sequence",
),
)
for column in ("operation_id", "status", "checkpoint_sha256"):
op.create_index(
op.f(f"ix_core_recovery_checkpoints_{column}"),
"core_recovery_checkpoints",
[column],
unique=False,
)
op.create_index(
"ix_core_recovery_checkpoints_operation_created",
"core_recovery_checkpoints",
["operation_id", "created_at"],
unique=False,
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
for table in (
"core_recovery_checkpoints",
"core_recovery_operations",
"core_distributed_leases",
"core_runtime_nodes",
):
if table in tables:
op.drop_table(table)
@@ -0,0 +1,110 @@
"""add controlled first-administrator enrollment evidence
Revision ID: f25c9d3e7a01
Revises: e14b8c2d6f90
Create Date: 2026-08-04 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "f25c9d3e7a01"
down_revision = "e14b8c2d6f90"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "core_first_admin_enrollments" not in tables:
op.create_table(
"core_first_admin_enrollments",
sa.Column("installation_id", sa.String(length=100), nullable=False),
sa.Column("state", sa.String(length=24), nullable=False),
sa.Column("generation", sa.Integer(), nullable=False),
sa.Column("token_sha256", sa.String(length=64), nullable=True),
sa.Column("token_fingerprint", sa.String(length=16), nullable=True),
sa.Column("issued_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("consumed_account_id", sa.String(length=36), nullable=True),
sa.Column("consumed_membership_id", sa.String(length=36), nullable=True),
sa.Column("consumed_tenant_id", sa.String(length=36), nullable=True),
sa.Column("consumed_email", sa.String(length=320), nullable=True),
sa.Column("consumed_display_name", sa.String(length=255), nullable=True),
sa.Column("consumed_request_sha256", sa.String(length=64), nullable=True),
sa.Column("issue_reason", sa.String(length=500), nullable=True),
sa.Column("event_count", sa.Integer(), nullable=False),
sa.Column("evidence_head_sha256", sa.String(length=64), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint(
"installation_id",
name=op.f("pk_core_first_admin_enrollments"),
),
)
op.create_index(
op.f("ix_core_first_admin_enrollments_state"),
"core_first_admin_enrollments",
["state"],
unique=False,
)
op.create_index(
op.f("ix_core_first_admin_enrollments_expires_at"),
"core_first_admin_enrollments",
["expires_at"],
unique=False,
)
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "core_first_admin_enrollment_events" not in tables:
op.create_table(
"core_first_admin_enrollment_events",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("installation_id", sa.String(length=100), nullable=False),
sa.Column("sequence", sa.Integer(), nullable=False),
sa.Column("event_type", sa.String(length=80), nullable=False),
sa.Column("generation", sa.Integer(), nullable=False),
sa.Column("evidence", sa.JSON(), nullable=False),
sa.Column("previous_sha256", sa.String(length=64), nullable=True),
sa.Column("event_sha256", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["installation_id"],
["core_first_admin_enrollments.installation_id"],
name=op.f(
"fk_core_first_admin_enrollment_events_installation_id_core_first_admin_enrollments"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_core_first_admin_enrollment_events"),
),
sa.UniqueConstraint(
"installation_id",
"sequence",
name="uq_core_first_admin_enrollment_event_sequence",
),
)
for column in ("installation_id", "event_type", "event_sha256"):
op.create_index(
op.f(f"ix_core_first_admin_enrollment_events_{column}"),
"core_first_admin_enrollment_events",
[column],
unique=False,
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "core_first_admin_enrollment_events" in tables:
op.drop_table("core_first_admin_enrollment_events")
if "core_first_admin_enrollments" in tables:
op.drop_table("core_first_admin_enrollments")
+36 -4
View File
@@ -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,16 +92,45 @@ The runner should execute an action plan as follows:
4. Run permission and policy checks.
5. Generate a consequence preview.
6. Reserve or verify the idempotency key.
7. Execute the owning module capability.
8. Record observed effects.
9. Emit events and audit records.
10. Mark the command complete, retryable, quarantined, or requiring manual
7. Create a durable recovery operation and acquire its execution fence.
8. Persist dispatch evidence before a non-atomic provider call.
9. Execute the owning module capability.
10. Verify the provider result and every announced effect using the action's
declared recovery checks.
11. Commit the local projection and verified recovery checkpoint together.
12. Emit events and audit records.
13. Mark the command complete, retryable, quarantined, or requiring manual
intervention.
The runner must never advance workflow state past a required side effect unless
the action definition explicitly allows asynchronous completion and the pending
state is visible.
For external and asynchronous effects, providers must preserve the distinction
between:
1. requested intent;
2. approved intent;
3. dispatched command;
4. possibly executed but unconfirmed outcome;
5. confirmed observed effect;
6. reconciled, corrected, or compensated outcome.
An API timeout after dispatch is not a failed effect and must not be retried as
an ordinary process failure or a fresh command. The runner records an unknown
outcome, releases its execution authority, and blocks continuation until an
operator or provider reconciliation proves either that the effect occurred or
that it is absent.
`ActionDefinition.recovery_mode` and `recovery_verification` are part of the
provider contract. The default is conservative forward recovery with explicit
provider-result and effect verification. Atomic mode is valid only when the
provider effect and its local projection share the same database transaction.
The actor context should retain the real identity/account,
represented function or party, delegation or power, and mandate/jurisdiction
references when applicable. Domain modules remain responsible for deciding
which of those references are required for their action.
## Failure States
Automation should use explicit failure states:
+10
View File
@@ -49,6 +49,15 @@ The broad writable root reduces approval churn. The explicit project trust entri
Each active repository has an `AGENTS.md` file. These files define ownership, module boundaries, and focused commands for Codex. Keep durable project conventions there instead of repeating them in every prompt.
Documentation is part of the completion criteria for every behavior change. The
owning module must update its manifest-driven `DocumentationTopic` contributions
for affected user and administrator workflows, settings, permissions,
limitations, and operational consequences. Feature documentation remains in the
feature module; the optional `govoplan-docs` module projects those contributions
without importing feature internals. Every module manifest must retain a static
user and administrator baseline even when runtime providers add configured-state
details.
Use `~/.codex/config.toml` for personal defaults, auth/runtime settings, writable roots, and trust decisions. Avoid checking in absolute-path writable roots or model preferences unless they are intentionally team-wide.
Use Gitea issues as the canonical backlog and state log. See `docs/GITEA_ISSUES.md` for label setup, TODO import, and Codex issue update commands. Durable docs should describe stable behavior; changing work state belongs on the issue.
@@ -81,5 +90,6 @@ PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versi
- Avoid broad recursive scans and full builds unless the change warrants them.
- Keep generated build/test folders ignored.
- Keep optional module behavior behind core registry/capability/module metadata boundaries.
- Run `tools/checks/check-manifest-shapes.py` after module behavior or manifest changes so user/admin documentation coverage remains complete.
- Create or update Gitea issues for TODOs, follow-ups, blockers, and feature requests instead of keeping local backlog files.
- Do not start persistent dev servers unless the user asks.
+1
View File
@@ -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
+32
View File
@@ -48,6 +48,35 @@ interface = how configured parts connect
data = what the operator must provide for this deployment
```
## Package Classes
The same signed package mechanism supports several explicitly named classes:
| Class | Purpose |
| --- | --- |
| `reference` | Prove a bounded journey, degraded states, recovery, and target-environment acceptance. |
| `product` | Provide a reusable operating capability such as governed communication, service-to-decision, procurement/contracts, or governed BI. |
| `sector` | Specialize terminology, forms, rules, process baselines, controls, reports, and integrations for an institutional sector without forking modules. |
| `deployment` | Declare a supported infrastructure topology, operational assumptions, health gates, and recovery evidence. |
| `integration` | Declare external systems, provider bindings, source-authority modes, maturity, required health, and reconciliation behavior. |
Package class is metadata and validation context, not additional authority. A
sector package does not become a module and cannot write another module's
tables. Packages may extend other packages only through versioned fragments and
must preserve provenance and parent constraints.
The contract enforces class-specific evidence. Reference packages require
target, recovery, security, operations, accessibility, privacy, and
documentation evidence. Deployment and integration packages require their
corresponding target/recovery/operations evidence, while integration packages
also name provider authority and minimum-maturity expectations. Preflight
blocks a missing, incompatible, or unhealthy provider. A derived package may
tighten parent module, capability, and provider requirements but cannot remove
or loosen them. Every non-documentation claim made by reference, deployment, or
integration packages carries a `sha256:<digest>` binding. Repository checks
recompute those hashes, while signed package verification protects the declared
manifest during transport.
## Package Model
A configuration package should be a signed, portable manifest plus module-owned
@@ -68,6 +97,9 @@ Required package metadata:
- preflight checks and post-import health checks
- migration or transformation rules for older package versions
- provenance, export source metadata, and signature metadata
- package class and optional parent package/version constraints
- source-authority bindings and provider-operation expectations for every
external integration used by the package
Portable configuration schemas follow `COMPATIBILITY_POLICY.md`: providers
write only their current schema version and read that version plus the previous
+54 -8
View File
@@ -36,7 +36,7 @@ set +a
| Setting | Required outside dev | Purpose |
| --- | --- | --- |
| `APP_ENV` | yes | Runtime profile. Use `prod`, `staging`, or a deployment-specific value outside local development. |
| `MASTER_KEY_B64` | yes | Fernet key or base64 encoded 32-byte key used for encrypted module secrets. Rotate through an explicit operator plan. |
| `MASTER_KEY_B64` | yes | Fernet key or base64 encoded 32-byte deployment root used for encrypted module secrets and, when enabled, the Encryption module's local server-envelope provider. Rotate only through an explicit provider-aware migration plan. |
| `DATABASE_URL` | yes | SQLAlchemy database URL for core and installed modules. SQLite is supported for dev/small installs; PostgreSQL is the preferred production target. |
| `ENABLED_MODULES` | yes | Comma-separated startup module set. Keep `tenancy,access` enabled; keep `admin` enabled for operator UI. |
@@ -57,6 +57,8 @@ PY
| `GOVOPLAN_MIGRATION_TRACK` | `release` | Use the release track for normal runtime and deployments. Use `dev` only for fresh/disposable databases that intentionally replay detailed development migrations. |
| `DEV_AUTO_MIGRATE_ENABLED` | `true` | Dev convenience only. Production should run migration commands explicitly during deployment. |
| `DEV_BOOTSTRAP_ENABLED` | `false` | Dev bootstrap only. `govoplan_core.devserver` and `govoplan/tools/launch/launch-dev.sh` default it to `true`; use controlled first-admin creation outside dev. |
| `FIRST_ADMIN_ENROLLMENT_TTL_SECONDS` | `1800` | Lifetime of a locally issued production enrollment credential. Allowed range: 60 seconds to 24 hours. |
| `FIRST_ADMIN_ENROLLMENT_FILE` | `/run/govoplan/first-admin-enrollment.json` | Local operator artifact. The command creates it with mode `0600` and never prints the secret. |
Operator rule: take a database backup before applying migrations or destructive
module retirement. For non-SQLite databases, configure deployment-specific
@@ -157,7 +159,8 @@ release evidence.
| --- | --- | --- |
| `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. |
| `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. |
| `CELERY_QUEUES` | `send_email,append_sent,notifications,calendar,dataflow,workflow,events,default` | Queue list expected by worker/process manager definitions. The `events` queue drains transactional platform events; `dataflow` drains trigger deliveries and schedules; `workflow` reconciles Workflow Engine instances and module standards. |
| `CELERY_QUEUES` | `send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default` | Queue list expected by worker/process manager definitions. Keep this aligned with every enabled module task route; Ops reports missing worker queue consumers. |
| `CELERY_VISIBILITY_TIMEOUT_SECONDS` | `3600` | Maximum time before Redis may redeliver work left unacknowledged by a lost worker. Set this above the longest supported task duration; changing it requires a worker-loss acceptance drill. |
| `PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS` | `8` | Failed durable consumer deliveries are quarantined after this many attempts. |
| `PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS` | `90` | Successful event envelopes older than this are removed by the daily retention task. Quarantined evidence is retained. |
@@ -165,7 +168,7 @@ Worker command:
```bash
python -m celery -A govoplan_core.celery_app:celery worker \
--queues send_email,append_sent,notifications,calendar,dataflow,events,default \
--queues send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default \
--loglevel INFO
```
@@ -177,6 +180,13 @@ crashes, and expired worker leases:
python -m celery -A govoplan_core.celery_app:celery beat --loglevel INFO
```
Before promoting a worker composition, run the repository worker-runtime drill
against the same Redis and Core build. It uses the bounded
`govoplan.worker.acceptance` task and records publish/consume, retry, warm
SIGTERM, and worker-loss redelivery evidence without accessing tenant data.
Production evidence must use the deployed queue configuration and a visibility
timeout that is longer than every supported business task.
### Storage
| Setting | Default | Notes |
@@ -292,8 +302,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.
@@ -343,7 +379,7 @@ the checked in `.env.example`. It runs:
- explicit `ENABLED_MODULES`
- explicit migrations and `--with-dev-data` bootstrap
- API via the module-aware devserver
- a Celery worker for `send_email,append_sent,notifications,calendar,default`
- a Celery worker for `send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default`
- WebUI through the Vite dev server
- durable local files under `runtime/production-like/files`
@@ -423,6 +459,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`
@@ -462,7 +506,9 @@ Run the rollback drill before relying on installer automation in a new
environment:
```bash
/mnt/DATA/git/govoplan/tools/checks/module-installer-rollback-drill.py --format json
/mnt/DATA/git/govoplan/tools/checks/module-installer-rollback-drill.py \
--format json \
--evidence-path runtime/module-installer/restore-drill-evidence.json
```
The drill uses temporary SQLite databases and simulated package commands. It
@@ -483,7 +529,7 @@ checks, catalog trust, signing, keyring, replay, and license operation.
## Operator Checklist
- Runtime secrets are injected outside git.
- `MASTER_KEY_B64` is set and backed up securely.
- `MASTER_KEY_B64` is set and backed up securely; restores of locally encrypted content fail closed without the exact matching key.
- Database backup and restore commands are tested.
- File/object storage is durable and backed up.
- `CORS_ORIGINS` and cookie settings match the deployed WebUI origin.
+6
View File
@@ -15,7 +15,11 @@ operator, and roadmap pages.
| Policy decision DTOs and provenance | `POLICY_CONTRACTS.md` | Shared explain/provenance shape; module-specific policy docs should link here. |
| Events and audit trace context | `EVENTS_AND_AUDIT.md` | Event dispatch semantics and audit payload conventions. |
| Action/effect automation layer | `ACTION_EFFECT_AUTOMATION_LAYER.md` | Action/effect contracts, consequence preview, runner semantics, and module boundary for automation. |
| External references and integration maturity | `EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md` | Stable external identity and cumulative connector maturity; configured source authority is defined by the meta target architecture. |
| Institutional context and governed references | `INSTITUTIONAL_CONTEXT_CONTRACT.md` | Shared temporal, actor/representation, institution, mandate, service, party, decision, evidence, legal-basis, information-governance, presentation, and geo DTO/provider contracts. |
| 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
@@ -33,7 +37,9 @@ 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. |
| UI/UX decisions | `UI_UX_DECISION_LEDGER.md` | Binding guided-UI decisions, open decisions, impact index, and review checklist. |
| Core interface migration | `INTERFACE_PATTERN_MIGRATION.md` | Core-owned settings, credential, retention, lifecycle, and shared-component evidence for the product pattern language. |
| Interface ethics and design doctrine | `INTERFACE_ETHICS_AND_DESIGN_DOCTRINE.md` | Product-level doctrine for context, decision, consequence, contestability, responsibility, and traceability. |
| Public-sector integration posture | `PUBLIC_SECTOR_INTEGRATION_STRATEGY.md` | Strategy index; executable target inventory lives in `govoplan-connectors`. |
| Configuration packages | `CONFIGURATION_PACKAGES.md` | Package model, provider contract, import/export flow, and tracking slices. |
+44
View File
@@ -0,0 +1,44 @@
# Durable Recovery Operations
Modules must use `begin_durable_recovery_operation` for work whose effects can
outlive the caller's SQLAlchemy transaction. The helper commits the canonical
request hash, recovery plan, precondition evidence, running state, and lease
fence before the caller mutates object storage, a queue, a filesystem, or an
external provider.
Each later checkpoint is written through an independent database session. A
business-transaction rollback therefore cannot erase evidence of an earlier
effect. Successful completion requires concrete verification checks and a valid
hash chain. Compensation likewise records recovery-required, recovering, and
verified-recovered checkpoints rather than reporting an ordinary failure.
A definitive pre-effect or provider rejection records terminal `rejected`
evidence instead of being mislabeled as success, atomic rollback, or recovery
work.
If a runtime disappears, another runtime may claim the operation only after the
lease expires. The takeover records both fences. A stale compensatable operation
becomes recovery-required; a stale forward-only or irreversible external effect
becomes outcome-unknown; a database-only atomic operation is recorded failed
because its transaction rolled back. Takeover never re-executes the original
request automatically.
Evidence and metadata may contain opaque references, digests, counts, and
provider result codes. They must never contain credentials or resolved secrets.
Ops is the platform surface for unresolved operation status; owning modules must
provide the reconciliation action and business-level explanation.
Database-only operations must use the durable handle's atomic terminal methods
when their module rows and final recovery checkpoint belong to one invariant.
Those methods stage the terminal checkpoint and lease release in the caller's
SQLAlchemy transaction, then commit the domain rows and recovery evidence
together. A failed commit rolls both back and leaves the previously durable
`running` record available for stale-fence handling; modules must not commit
their domain state first and close an `atomic` recovery record afterwards.
An owning module may reconcile an `outcome_unknown` provider effect through the
claimed durable handle's `resolve_unknown` method. External evidence that the
effect occurred records verified success. Evidence that it did not occur moves
the operation through recovery-required and recovering to verified recovered,
so any later attempt must use a new deliberate idempotency key. The method does
not infer provider state and requires the same terminal verification structure
and hash-chain checks as ordinary completion.
@@ -35,6 +35,26 @@ Connectors must declare and document the maturity they actually implement.
handling, deletion semantics, and observable failures. A link-only connector
must not imply that GovOPlaN holds an authoritative copy.
## Source Authority Is A Separate Dimension
Integration maturity states what an adapter is capable of doing. It does not
decide which system owns truth for a configured object or field group. A
binding separately selects one of the source-authority modes defined by the
[institutional governance target architecture](../../govoplan/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md):
- `native_authoritative`
- `external_authoritative`
- `external_mirror`
- `governed_sync`
- `governance_overlay`
- `linked_reference`
A connector can therefore support `synchronize` while a tenant deliberately
uses it only as an external mirror. Conversely, a native GovOPlaN object may
retain link-only references to several external systems. Authority may be
narrowed by tenant, organization, service, object type, object, field group, or
process step and must be visible in provenance and configuration preflight.
## Domain Ownership
- Domain modules own native GovOPlaN objects and their authorization.
+72 -57
View File
@@ -16,6 +16,9 @@ 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).
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).
Those product documents are canonical; this Core roadmap remains their
technical sequencing and module-routing companion.
@@ -64,8 +67,9 @@ verify or reverse those effects.
`INTERFACE_ETHICS_AND_DESIGN_DOCTRINE.md`.
- Automation must use governed action/effect contracts, not hidden side
effects. The first automation layer is defined in
`ACTION_EFFECT_AUTOMATION_LAYER.md` and should start in `govoplan-workflow`
unless a separate automation module becomes justified.
`ACTION_EFFECT_AUTOMATION_LAYER.md`; its first runner lives in
`govoplan-workflow-engine`. Create a separate automation module only if the
scheduler/action runtime outgrows workflow coordination.
- Encrypted postboxes are a strategic target. Early postbox, access, and
identity-trust contracts should stay compatible with the E2EE architecture in
`POSTBOX_E2EE_ARCHITECTURE.md`.
@@ -144,8 +148,9 @@ pattern exists.
| Structured forms and validation | `govoplan-forms` |
| Uploaded files and managed storage | `govoplan-files` |
| Case record and lifecycle | `govoplan-cases` |
| Workflow transitions and automation | `govoplan-workflow` |
| Action/effect catalogue and automation runner | first `govoplan-workflow`; possible future `govoplan-automation` if it outgrows workflow |
| Workflow runtime, transitions, and automation | `govoplan-workflow-engine` |
| Workflow definition editing | optional `govoplan-workflow` |
| Action/effect catalogue and automation runner | first `govoplan-workflow-engine`; possible future `govoplan-automation` only if it outgrows workflow |
| Internal work queues and tasks | `govoplan-tasks` |
| Appointment proposals and booking | `govoplan-appointments`, `govoplan-calendar` |
| Postbox, email, and notifications | `govoplan-postbox`, `govoplan-mail`, `govoplan-notifications` |
@@ -153,13 +158,18 @@ pattern exists.
| Organizational structures, units, and functions | `govoplan-organizations` |
| Identity-to-function assignments and directory synchronization | `govoplan-idm` |
| Identity trust, device keys, and encrypted postbox key contracts | `govoplan-identity-trust`, `govoplan-access`, `govoplan-postbox` |
| Service directory/catalog | `govoplan-portal` |
| Service directory presentation | `govoplan-portal` |
| Versioned institutional service definition | shared service contract first; candidate `govoplan-services` after reuse proof |
| Mandate, jurisdiction, and institutional authority | shared mandate contract first; candidate `govoplan-mandates` after reuse proof |
| Procedure-local parties and representation | shared party contract first; candidate `govoplan-parties` after reuse proof |
| Formal institutional decisions | shared decision contract first; candidate `govoplan-decisions` after reuse proof |
| Permit/document generation | `govoplan-templates`, `govoplan-dms` |
| Payment capture and accounting handoff | `govoplan-payments`, `govoplan-ledger` |
| Authentication projection, roles, permissions, acting context | `govoplan-access` |
| Tenants, policy, and audit | `govoplan-tenancy`, `govoplan-policy`, `govoplan-audit` |
| External software integration | `govoplan-connectors` |
| Recurring extraction and transformation | possible future `govoplan-datasources`, possible future `govoplan-dataflow` |
| Governed data/register catalogue | `govoplan-datasources` |
| Recurring extraction and transformation | `govoplan-dataflow` over Datasources and Connectors contracts |
| Reports, BI, and management visibility | `govoplan-reporting` |
## Configuration And Safety Target
@@ -204,8 +214,9 @@ an editor applies a high-impact configuration change.
## Reference Journeys
The active sequence is selected. Workflow remains deliberately deferred and is
not a dependency of these journeys.
The active sequence is selected. Workflow Engine and its optional editor are
now available foundations, but a reference journey does not depend on Workflow
unless its package explicitly composes and proves it.
### Journey 1: Campaign Demonstration Composition
@@ -256,9 +267,11 @@ The result must preserve official-key mappings, organizational and reporting
date semantics, quality findings, quarantine/replay, transparent calculation,
and reproducible promotion between development, test, and production.
Reporting consumes the product. Create `govoplan-datasources` or
`govoplan-dataflow` only after the concrete path proves repeated ownership that
does not belong to connectors, Reporting, or the producing domain module.
Reporting consumes the product. Datasources owns the governed source and
materialization lifecycle; Dataflow owns typed transformation/run lineage;
Connectors owns external transport. The concrete path must now prove those
implemented boundaries and expose any missing contracts instead of recreating
them inside Reporting or a producing domain module.
### Journey 5: Collaborative Document Lifecycle
@@ -336,8 +349,9 @@ Create or refine in this order:
access.
4. `govoplan-cases`: case record, status, assignments, deadlines, and case
evidence.
5. `govoplan-workflow`: state machine, transitions, commands, and module
handoff.
5. `govoplan-workflow-engine`: state machine, transitions, commands, module
handoff, and resumable execution; optional `govoplan-workflow` supplies the
editor.
6. `govoplan-tasks`: work queues, assignments, due dates, and follow-ups.
7. `govoplan-templates`: permit/decision document generation.
8. `govoplan-postbox`, `govoplan-mail`, `govoplan-notifications`: applicant and
@@ -526,31 +540,34 @@ Refine:
dashboard data.
- `govoplan-search`: permissioned cross-module discovery.
Create only when justified:
Refine the existing owners:
- `govoplan-datasources`: source catalog, connection profiles, schema discovery,
freshness, provenance.
- `govoplan-dataflow`: transformations, validation, lineage, scheduled runs,
publication outputs.
- `govoplan-projects`: only if OpenProject connectors and existing cases/tasks
cannot cover the required semantics.
- `govoplan-datasources`: governed data/register catalog, live/cached/static
sources, staging, immutable materializations, freshness, quality, legal and
organizational context, and provenance. Connector profiles and credentials
remain in Connectors.
- `govoplan-dataflow`: typed transformations, validation, lineage, manual,
scheduled and event-triggered runs, reusable definitions, and publication
outputs.
- `govoplan-projects`: native projects, portfolios, milestones, goals,
dependencies, capacity, outcomes, and external OpenProject references;
Connectors owns OpenProject transport and synchronization.
Reference journey: monthly data extraction, transformation, validation, approval,
publication, and reporting.
Recurring extraction/transformation should start as a configuration package
across connectors, files, workflow, reporting, and templates. The package should
register sources, declare schemas, define mapping/validation versions, schedule
runs, produce previewable diffs, write governed outputs, and preserve lineage,
hashes, operator actions, and audit evidence. Create `govoplan-datasources` or
`govoplan-dataflow` only after this work exposes repeated contracts that do not
belong to existing modules.
Recurring extraction/transformation should be delivered as a configuration
package across Connectors, Datasources, Dataflow, Workflow Engine, Reporting,
Files, and Templates. The package should register sources, declare schemas,
define mapping/validation versions, schedule runs, produce previewable diffs,
write governed outputs, and preserve lineage, hashes, operator actions, and
audit evidence.
Exit criteria:
- connector catalog exists before building many adapters
- dataflow is created only after recurring transformation becomes product
behavior
- datasource and dataflow ownership remains provider-neutral and is proved by
the recurring transformation package
- reporting consumes governed sources with provenance
## Implementation Gates
@@ -591,23 +608,25 @@ in the capability waves:
4. Implement one data-backed Templates/Reporting path and safe HIS-style deep
launch.
5. Extend that concrete source into one governed university analytical data
product before generalizing data-source or dataflow ownership.
product and use it to harden the existing Datasources/Dataflow ownership,
quality, lineage, and promotion contracts.
6. Implement Files-backed DMS versions and one provider-neutral collaborative
editing lifecycle, then connect Records handoff.
7. Maintain already integrated Calendar/Scheduling/Poll and other foundations;
activate another capability cluster only when the current journey needs it
or the product roadmap explicitly reprioritizes it.
8. Resume Workflow only by explicit product decision and constrain it with
stable actions from one demonstrated package.
8. Extend Workflow Engine and the optional editor only through stable actions
and one demonstrated package at a time.
## Deliberate Deferrals
Defer these until a reference journey proves the need:
- full ERP replacement
- native project management beyond connector support
- an unbounded general-purpose dataflow platform; the bounded governed BI
reference journey is selected
- unsupported breadth in native project management before the Projects/OpenProject
boundary is proved in a reference journey
- unbounded Dataflow operators or execution engines without golden-flow,
quality, lineage, resource-limit, and recovery evidence
- every possible public-sector protocol adapter
- rich LMS behavior beyond training administration
- full qualified digital signing/trust services beyond the identity-trust and
@@ -630,26 +649,26 @@ repositories or to explicit missing-module decisions.
| Fully UI-managed configuration with safety controls | `govoplan-admin`, `govoplan-core`, `govoplan-policy`, `govoplan-access`, `govoplan-audit` | `GovOPlaN/govoplan-core#218` |
| Access as a module | `govoplan-access` | `GovOPlaN/govoplan-access#7` |
| Interface ethics and decision-consequence doctrine | `govoplan-core` plus all UI-owning modules | `GovOPlaN/govoplan-core#227` |
| Action/effect automation layer | first `govoplan-workflow`; possible future `govoplan-automation` | `GovOPlaN/govoplan-workflow#1` |
| Action/effect automation layer | `govoplan-workflow-engine`; possible future `govoplan-automation` only after boundary proof | `GovOPlaN/govoplan-workflow#1` |
| E2EE role/function postbox architecture | `govoplan-postbox`, `govoplan-identity-trust`, `govoplan-access`, `govoplan-policy`, `govoplan-audit` | `GovOPlaN/govoplan-postbox#15`, `GovOPlaN/govoplan-identity-trust#1` |
| Identity, account, function, role, right semantic model | `govoplan-access` | `GovOPlaN/govoplan-access#9` |
| Role-based service directory/catalog | `govoplan-portal` | `GovOPlaN/govoplan-portal#1` |
| Role-based service directory presentation | `govoplan-portal`; reusable service definition is a shared contract and candidate `govoplan-services` | `GovOPlaN/govoplan-portal#1` |
| Unified inbox across tasks, postbox, notifications, and portal | `govoplan-core` coordination plus owning modules | `GovOPlaN/govoplan-tasks#1`, `GovOPlaN/govoplan-notifications#1` |
| OpenProject API / project management connector | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#1` |
| Native project-management module decision | connector-first through `govoplan-connectors`; no native project module yet | `GovOPlaN/govoplan-core#196`, `GovOPlaN/govoplan-connectors#1` |
| Datasources for databases, CSV, files, APIs | no repository yet; start with connectors/files/reporting and create `govoplan-datasources` only after the first package proves shared source-catalog ownership | `GovOPlaN/govoplan-core#197` |
| Dataflow for pipelines, BI, publication | no repository yet; start with workflow/reporting/connectors and create `govoplan-dataflow` only after repeated pipeline/lineage contracts emerge | `GovOPlaN/govoplan-core#198` |
| Monthly datasource and transformation workflows | first as configuration package across connectors, files, workflow, reporting, and templates | `GovOPlaN/govoplan-core#216` |
| Native project-management module | `govoplan-projects`; OpenProject transport remains connector-owned | `GovOPlaN/govoplan-projects#1`, `GovOPlaN/govoplan-connectors#12` |
| Datasources for databases, CSV, files, APIs | `govoplan-datasources`, with external protocol and credential ownership in Connectors | `GovOPlaN/govoplan-datasources#1` |
| Dataflow for pipelines, BI, publication | `govoplan-dataflow` over Datasources and module-owned providers | `GovOPlaN/govoplan-dataflow#1` |
| Monthly datasource and transformation workflows | configuration package across Connectors, Datasources, Dataflow, Workflow Engine, Reporting, Files, and Templates | `GovOPlaN/govoplan#8`, `GovOPlaN/govoplan-dataflow#17` |
| Templates for letters, emails, forms, reports | `govoplan-templates`, separate from reporting | `GovOPlaN/govoplan-core#190`, `GovOPlaN/govoplan-templates#1` |
| Reporting and BI | `govoplan-reporting`, separate from templates | `GovOPlaN/govoplan-core#190`, `GovOPlaN/govoplan-reporting#1` |
| File connectors: Nextcloud, Seafile, SMB, NFS | `govoplan-files` | `GovOPlaN/govoplan-files#15` |
| Public-sector software integration catalogue | `govoplan-connectors` with core strategy index | `GovOPlaN/govoplan-core#191`, `GovOPlaN/govoplan-connectors#2` |
| Public-sector integration landscape catalogue | `govoplan-connectors` with core tracking | `GovOPlaN/govoplan-core#215` |
| Cases module concept | `govoplan-cases` | `GovOPlaN/govoplan-core#174` |
| Workflow module concept | `govoplan-workflow` | `GovOPlaN/govoplan-core#175` |
| Workflow runtime/editor split | `govoplan-workflow-engine` runtime plus optional `govoplan-workflow` editor | `GovOPlaN/govoplan-workflow#12`, `GovOPlaN/govoplan-workflow#13` |
| Connectors module concept | `govoplan-connectors` | `GovOPlaN/govoplan-core#176` |
| Adrema-style address and distribution-list management | `govoplan-addresses` | `GovOPlaN/govoplan-addresses#1` |
| Consume sources and become a governed source | `govoplan-connectors` plus possible future `govoplan-dataflow` | `GovOPlaN/govoplan-connectors#3`, `GovOPlaN/govoplan-core#198` |
| Consume sources and become a governed source | Connectors acquires, Datasources governs/materializes, Dataflow transforms/publishes | `GovOPlaN/govoplan-connectors#3`, `GovOPlaN/govoplan-datasources#3` |
| Governed connector configuration, dry-run, and simulation runtime | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#6` |
| Terminfindung and meeting scheduling polls | `govoplan-scheduling`; calendar primitives remain in calendar | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-scheduling#1` |
| Terminplaner and calendar primitives | `govoplan-calendar` | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-calendar#1` |
@@ -671,27 +690,23 @@ repositories or to explicit missing-module decisions.
Boundary rationale lives in `MODULE_ARCHITECTURE.md`. Current decisions:
- templates and reporting are separate modules
- RSS/source consume-publish starts in connectors; datasources/dataflow are not
repositories yet
- RSS/source consume-publish starts in Connectors; governed source identity and
snapshots belong to Datasources and transformations belong to Dataflow
- calendar, scheduling, and appointments are three separate modules
- forms definitions and forms runtime are separate responsibilities
- OpenDesk is an integration profile across modules, not a monolithic module
- OpenProject is connector-first; no native projects module yet
- OpenProject transport is connector-owned; native portfolio/project semantics
belong to Projects
- public-sector integration strategy stays in core; executable catalogue work
lives in connectors
- encrypted postbox and identity-trust are strategic contracts, not mail-module
behavior
- automation starts as workflow-owned action/effect execution and may split into
a dedicated module only after the runner becomes broader than workflow
The following modules are intentionally not created yet:
- `govoplan-datasources`
- `govoplan-dataflow`
- `govoplan-projects`
Create a repository only after a concrete implementation package proves that
existing connector, files, reporting, workflow, or task ownership is too narrow.
- automation starts in Workflow Engine and may split into a dedicated module
only after the runner becomes broader than workflow
- Mandates, Services, Parties, and Decisions begin as shared semantic contracts;
create repositories only after independent persistence, lifecycle, security,
and multiple-consumer evidence passes the repository threshold in the
institutional governance target architecture
Core keeps the strategy index in
`PUBLIC_SECTOR_INTEGRATION_STRATEGY.md`: integration postures, default
+159
View File
@@ -0,0 +1,159 @@
# Institutional Context And Governed References
GovOPlaN consequential work must retain enough context to answer who acted,
for whom, through which function, under which mandate and jurisdiction, using
which rule and evidence versions, and with which requested and observed effect.
The shared contract lives in `govoplan_core.core.institutional`.
Core owns reference shapes and provider protocols only. It does not own shared
Mandate, Service, Party, Decision, evidence, or geography tables. Domain modules
own persistence and authorization; optional capabilities resolve the references.
## Envelope
`GovernedContextEnvelope` version 1 carries:
- a tenant and `TemporalRevision` with validity, recording, supersession, and
change reason;
- the real account or service account and represented account, function,
procedure party, assignment, delegation/power, and mandate;
- institution, organization unit, function, task, mandate, jurisdiction,
service, case, party, work item, workflow, approval, decision, and record
references;
- versioned legal bases and evidence references;
- information classification, purposes, retention/holds, minimization, and
disclosure state;
- external-source authority, maturity, freshness, health, and conflict state;
- language, accessibility, channel, explanation, and availability references.
Every institutional reference includes the owner module, tenant, stable object
identity, optional version/effective instant, and a protected display label.
Cross-tenant references are rejected. Safe serialization omits labels,
inspection URLs, formal reasoning, operative results, and conditions unless a
caller explicitly requests the protected projection.
## Semantic Providers
The first provider-neutral capabilities are:
- `mandates.resolver`: resolve competence for a task/authority type at an
effective instant and return the governing Mandate definition and evidence;
- `services.definitions`: obtain versioned institutional service definitions;
- `parties.resolver`: obtain effective procedure-local parties and powers of
representation without copying Identity or Organizations subjects; and
- `decisions.registry`: record and retrieve formal Decisions under optimistic
revision control.
The Mandate, Service, Party/representation, and Decision DTOs have strict
mapping round-trips so they can cross capability, event, package, and storage
boundaries without shared ORM models. Their lifecycle states are explicit:
Mandates distinguish draft/active/suspended/replaced/retired, Services retain
publication state, Parties retain effective representation and revocation, and
Decisions retain correction, revocation, and supersession references.
The DTOs are a repository threshold, not a mandate to create four modules.
Independent persistence, lifecycle, security/operations behavior, release
reason, reuse, and tests are still required before extraction.
Mandate resolution is deterministic: Core filters candidates by tenant,
effective interval, active state, task and authority type, stable
organization/function identity, jurisdiction coverage, and subject type. A
result is competent only when exactly one matching Mandate remains and it has
no unresolved conflicts. Evidence from matching definitions is deduplicated
and retained in the explanation result. `revise_mandate_definition` applies
optimistic concurrency and the allowed activation, suspension, replacement,
and retirement transitions while leaving the previous revision immutable.
`revise_formal_decision` provides the equivalent lifecycle primitive for
formal outcomes. Every accepted transition requires a new recorded revision
and change reason, links `supersedes_ref` to the prior version, updates the
authority envelope to the new version, and records explicit correction or
revocation provenance. Terminal and backward transitions fail closed. Each
Decision also records whether responsibility was human, human-reviewed
automation, or an automated service account acting under mandate. Automation
preparation/recommendation references remain inspectable without being
mistaken for the responsible outcome.
Procedure-party corrections use `revise_procedure_party`: the stable party
identity is retained, a new revision and reason are required, stale writes are
rejected, and revoked/expired/superseded assignments are terminal.
`revoke_party_representation` separately records when a limited power ceased
to authorize actions. This allows consuming procedures to evaluate historical
delivery or representation authority without rewriting Identity,
Organizations, or Addresses records.
Service templates and package/tenant specializations use
`derive_service_restriction`. The derived definition retains an explicit
parent-version reference, cannot extend the parent's validity, audience,
channels, or publication ceiling, and cannot remove inherited prerequisites,
required evidence, legal bases, or bindings. This is the fail-closed semantic
rule; configuration-package signature and provenance checks remain the package
transport rule.
`ServiceAvailabilityRequirement` represents module, capability, mandate,
policy, connector, maintenance, audience, and configuration prerequisites with
an explicit unavailable-or-hidden failure mode and explanation reference. The
optional `services.availability` evaluator returns policy-scoped boolean
assessments, reason codes, and evidence. Unknown consequential requirements
fail closed; a reference itself never grants access.
## Service Launch
`ServiceLaunchRequest` and `ServiceLaunchResult` define the owner-neutral
boundary between Portal entry and a case, form, or workflow runtime effect.
The request carries the exact published Service definition, exact selected
binding, tenant, acting identity, timezone-aware request time, bounded
parameters, and idempotency key. The result must retain that exact Service and
binding, a same-tenant target reference, optional same-tenant evidence, and
only a relative or credential-free HTTP(S) destination.
`service_launch_capability(kind)` maps bindings to owner capabilities:
- `case` -> `cases.service_launcher`
- `form` -> `forms_runtime.service_launcher`
- `workflow` -> `workflow_engine.service_launcher`
`FormDefinition`, `FormFieldDefinition`, and `forms.definitions` provide the
owner-neutral exact-schema boundary used by Forms Runtime. Definitions carry an
exact tenant/revision, field types/options/constraints/defaults, publication,
draft, attachment, signature, policy, and handoff requirements. Forms owns
those immutable definitions; Forms Runtime persists instances and validation
evidence. A form Service binding uses `<form-id>/<revision>` and the launcher
rejects missing, superseded, unpublished, cross-tenant, or invalid definitions.
Portal may discover and invoke those capabilities but cannot write owner
tables. The owner must revalidate its definition/binding and current
authorization, produce its normal audit/event state, and make replay after an
ambiguous response safe. If the capability is absent, the service is
explainably unavailable. URL-only entries pass through the same launch-time
availability check and destination validation. Forms Runtime now supplies the
definition-aware form launcher when both Forms and Forms Runtime are active;
otherwise Portal continues to fail closed.
## Propagation
`PlatformEvent`, `ActionExecutionRequest`, and `AuditEvent` can carry the
envelope. Audit persistence stores only its safe projection; platform-event
outbox serialization preserves it across asynchronous delivery. A module must
not invent a parallel context dictionary when the shared fields apply.
## First Proof
Committee's `committee.decision_path` capability is the first bounded proof. It
requires one effective, conflict-free Mandate covering the organization unit
function, and jurisdiction, an approval reference, fact evidence, versioned legal bases,
operative result, and reasoning. It emits a reconstructable `FormalDecision`,
including requested/observed effects and information governance. If a Decision
registry is installed it persists there; Committee does not take ownership of
the generic Decision lifecycle.
## Compatibility And Security
- Contract version changes follow Core compatibility policy.
- Unknown tenant or reference-kind combinations fail closed.
- Datetimes that affect authority must be timezone-aware.
- Protected labels, reasoning, evidence inspection links, and source details
remain subject to the owning module's access policy.
- References do not grant access to their targets.
- Evidence and audit payloads must contain stable references/checksums, not
plaintext secrets.
+28
View File
@@ -0,0 +1,28 @@
# 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 configuration primitives | Cross-module component contract | Dialog focus, blocker structure, disabled-action focus, contextual help, unsaved changes, confirmation, loading, alerts, problem lists, and policy provenance are centralized | Core component tests and module-permutation build |
## Boundary
Files and Mail are the first two external consumers of the layered
server/credential/policy pattern. Their own repositories retain provider
discovery, transport behavior, authorization, and migration evidence. Remaining
module surfaces are tracked by bounded module-owned issues under GovOPlaN #11;
they are not reasons to add sibling-private behavior to Core.
Raw JSON remains permitted only for diagnostics, expert inspection,
interchange, or conflict evidence. It is not a primary Core configuration
editor.
+278 -5
View File
@@ -13,6 +13,10 @@ Policy decision, source provenance, and explain-response contracts are tracked
in [`POLICY_CONTRACTS.md`](POLICY_CONTRACTS.md).
The experimental remote WebUI bundle loading design is tracked in
[`REMOTE_WEBUI_BUNDLES.md`](REMOTE_WEBUI_BUNDLES.md).
The cross-product semantic layers, source-authority modes, and candidate
Mandates, Services, Parties, and Decisions boundaries are canonical in the
meta repository's
[`INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md`](../../govoplan/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
## Layer Model
@@ -24,6 +28,36 @@ The experimental remote WebUI bundle loading design is tracked in
| Business modules | Public-sector workflows | campaigns, cases, forms, approvals, appointments |
| Connector modules | External system integration | FIT-Connect, XÖV/XTA, DMS/eAkte, ERP, IDM |
This table is the technical composition model. The product portfolio uses a
more detailed institutional layer model, but it does not change dependency
direction: Core provides contracts and composition; modules own semantics;
packages compose modules.
## Institutional Semantic Boundaries
Cross-module references must keep these answers distinct:
- Organizations owns where structures, units, and functions exist.
- Identity owns who a subject is; Access owns accounts, roles, permissions,
and authorization decisions; IDM owns effective function assignments.
- A Mandates capability will answer why a unit or function is competent for a
task, jurisdiction, subject, or period. It must not become another RBAC
system.
- A Services capability will own versioned institutional service definitions;
Portal presents and starts them.
- A Parties capability will own procedure-local participant roles,
representation, and delivery authority; it must reference rather than copy
Identity, Organizations, and Addresses subjects.
- A Decisions capability will own formal institutional outcomes and their
authority, facts, rules, reasoning, effects, correction, and review.
Approvals owns review gates, Committee owns deliberation/votes, and Workflow
Engine owns coordination.
Start each missing concept as a versioned DTO/provider contract used by a
bounded journey. A repository is justified only when the concept gains
independent persistence, lifecycle, security/operations behavior, release
reason, and reuse. Core must not store these domain objects.
## Kernel Responsibilities
The kernel target owns:
@@ -98,6 +132,11 @@ The following contracts are the baseline API that modules can rely on:
- navigation metadata contract
- command/event envelope contract
- policy decision and source provenance contract in `govoplan_core.core.policy`
- external object reference and integration-maturity contract in
`govoplan_core.core.external_references`
- action/effect preview and execution contract in
`govoplan_core.core.automation`
- workflow definition contribution and runtime-worker contracts
Changes to these contracts must be versioned or accompanied by compatibility shims.
@@ -115,6 +154,30 @@ may extend the kernel by adding explicit contracts, but existing contracts must
remain source-compatible through the 0.1.x split line unless a migration shim
and deprecation note are provided.
### Architecture Metadata
`ModuleManifest.architecture` is the backward-compatible, versioned product-
portfolio declaration for:
- module kind and institutional architecture layer;
- evidence-backed maturity claim (`concept`, `scaffold`, `vertical_slice`,
`reference_ready`, `supported`, or `lts`);
- owned and explicitly non-owned concepts;
- supported source-authority modes;
- reference packages, tested providers, and known limits;
- migration, upgrade, recovery, security, operations, and documentation
evidence references.
Core validates the claim and all provider references during registry startup.
`reference_ready`, `supported`, and `lts` claims require a named reference
package and the cumulative evidence set; target-tested providers additionally
require provider evidence. A supported module with migrations must include
migration evidence. Signed release catalogs retain and revalidate the
declaration. The meta manifest check validates repository evidence paths and
provides an opt-in `--require-architecture` rollout gate. Platform metadata,
Docs, and Ops project the same declaration. A module cannot make itself
supported solely by changing its maturity string.
Known access-related capability names are defined in
`govoplan_core.core.access`, including:
@@ -144,12 +207,20 @@ Other stable runtime capabilities currently include:
- `identity.directory` and `identity.search`
- `organizations.directory`
- `idm.directory`
- `calendar.outbox` and `calendar.scheduling`
- `idm.directory`, `idm.function_assignments`, `idm.relationships`, and
`idm.assignment_lifecycle`
- `calendar.outbox`, `calendar.scheduling`, `calendar.invitations`, and
`calendar.externalProfiles`
- `poll.scheduling`
- `notifications.dispatch`
- `workflow.definitionContributions` and `workflow.runtimeWorker`
The provider-neutral `idm.relationships` contract carries tenant-scoped typed
groups, effective-dated identity relationships, and explicit membership
decisions. It deliberately does not expose IDM persistence models or imply an
Access permission. Consumers can retain source revisions and inclusion or
exclusion provenance while remaining optional-module safe.
Modules contribute reusable process baselines through
`ModuleManifest.workflow_definitions`. Each contribution pins its origin module
and version, stable key, schema and content hash, native graph/BPMN content,
@@ -180,11 +251,52 @@ intended for SemVer major-version lines. Missing optional interfaces are
allowed, but an installed provider with an incompatible version blocks
activation because the integration would otherwise bind to an unsafe API.
### Source Authority And Provider Operations
Integration maturity and configured authority are independent. The existing
external-reference maturity ladder describes whether an adapter can discover,
link, search, read, publish, synchronize, migrate, or replace. A binding must
also state whether GovOPlaN is native authoritative, the external system is
authoritative, GovOPlaN keeps a mirror, both sides use governed sync, GovOPlaN
adds only a governance overlay, or the object is link-only.
`ModuleManifest.external_providers` composes existing contracts rather than
replacing them. Each declaration describes owned object/field groups, authority modes,
operations, revisions, freshness, health, limits, idempotency, conflicts,
outcome-unknown handling, evidence, correction/compensation, reconciliation,
outage behavior, classification, purpose, retention, and secret requirements.
Core owns the typed declaration and validation. Connectors and domain modules
own the actual protocol and domain behavior; configuration packages select the
effective mode and block missing/incompatible/unhealthy providers; Docs and Ops
explain the result. Effect-capable declarations fail validation unless their
retry, concurrency, outcome-unknown, correction, reconciliation, evidence,
audit, timeout, outage, classification, purpose, retention, and secret behavior
is explicit.
Declarations are release-time capability claims. Configured state is projected
separately through `ModuleManifest.external_provider_state_providers`. A state
provider receives a bounded tenant context and returns one sanitized observation
per configured binding: stable binding reference, effective authority mode,
active/configured state, health, freshness, conflict, recovery readiness,
observation/last-success time, and scalar metrics. Core validates and aggregates
those observations, isolates provider failures, and never accepts URLs,
credentials, or arbitrary nested metadata as runtime-state fields. Docs removes
binding-level detail from ordinary-user projections; Ops may show the full
sanitized operator projection.
Configuration-package preflight selects the exact requested binding from this
runtime state before evaluating authority, health, freshness, and recovery. A
healthy sibling binding therefore cannot mask an unhealthy required binding.
Providers with multiple configurations must use non-secret, stable references
such as `calendar:sync-source:<id>`.
Current named interfaces, generated from the source manifests by the workspace
contract checks, are:
- `addresses.contact_writer`, `addresses.lookup`, `addresses.recipient_source`
- `calendar.outbox`, `calendar.scheduling`
- `addresses.contact_point_resolution`, `addresses.contact_writer`,
`addresses.lookup`, `addresses.recipient_source`
- `calendar.external_profiles`, `calendar.invitations`, `calendar.outbox`,
`calendar.scheduling`
- `campaigns.access`, `campaigns.delivery_tasks`,
`campaigns.mail_policy_context`, `campaigns.policy_context`,
`campaigns.retention`
@@ -593,6 +705,46 @@ Rules:
one migration run, and do not switch a database between tracks unless it is a
disposable development database.
### Shared State And Runtime Ordering
Multi-host application roles use the `shared` state profile. In that profile,
PostgreSQL, Redis, a stable installation identifier, and S3-compatible object
storage are mandatory. Module durable artifacts must use Core's object-storage
contract and module-owned opaque key namespaces; node-local paths are limited
to temporary materialization. Same-host replicas may use the `host-shared`
profile and one shared volume.
Only the migration command mutates schema. PostgreSQL migration runs acquire a
deployment-wide advisory lock before module pre-tasks, Alembic, and post-tasks.
API, worker, and scheduler roles wait for exact configured migration heads and
fail closed instead of applying migrations during startup.
Runtime roles register identity, software/module composition, queues, heartbeat,
and drain state in PostgreSQL. Singleton work must use a distributed lease and
validate its monotonically increasing fencing token at the consequential
commit. See `STATE_AND_RECOVERY_CONTRACT.md` for the complete contract.
### Recovery Evidence
Operations spanning transactions, object storage, queues, or external systems
must choose an explicit Core recovery mode: atomic, compensation,
snapshot-restore, forward-recovery, or irreversible. Plans require verification
steps and mode-specific recovery material. Use idempotency keys, append-only
evidence checkpoints, and a runtime fence where work may race across nodes.
The recovery ledger is a shared primitive, not automatic coverage. A module may
claim its guarantees only after its operation records preconditions before side
effects, transitions partial/unknown outcomes honestly, and records verified
completion or recovery. Plaintext secrets must never enter recovery metadata or
evidence.
For a conclusive external result, modules may commit their local success
projection and the verified terminal checkpoint in one database transaction via
`DurableRecoveryOperation.commit_verified_success`. This does not make the
external provider effect atomic. It prevents a local `succeeded` state from
becoming authoritative when the recovery evidence chain is damaged or the
terminal checkpoint cannot commit.
## Install, Uninstall, And Catalogs
Core owns the install plan, signed catalog validation, license entitlement
@@ -682,12 +834,34 @@ the shared loading and retryable error state around route rendering. The
initial static import closure and largest asynchronous chunk are enforced by
the budgets documented in [WEBUI_BUNDLE_BUDGETS.md](WEBUI_BUNDLE_BUDGETS.md).
Every public platform interface has a stable declaration identity. Backend
routes, capabilities, interfaces, search providers/sources, permissions,
frontend routes/navigation, and View surfaces derive that identity from typed
`ModuleManifest` values. Typed WebUI capabilities declare IDs for settings,
admin sections, widgets, search contexts, and extension actions. Shared form
and action controls accept `interfaceId` and `helpTopicId`; use module-namespaced
values when another contract, documentation topic, or automated check must
refer to the control across source changes. The static inventory assigns a
line-independent source anchor when an explicit ID is absent and reports that
fact for later review.
Core exposes the sanitized runtime declaration set at
`GET /api/v1/platform/interface-catalog`. The endpoint is read-only, requires
`admin:module:read` or `system:settings:read`, and includes only modules
effective in the caller's active tenant context. It never serializes factories,
credentials, executable callbacks, or mutable module state. Registry validation
rejects conflicting declaration IDs before startup.
WebUI modules receive only the core route context:
- `settings`
- `auth`
A module should call its own API client and module-owned backend routes. Shared API helpers should live in core only when they are truly platform-level concerns.
For ordinary JSON mutations, use Core's `apiPostJson` and `apiPatchJson`
helpers. They preserve the shared authentication, CSRF, error, and request
invalidation behavior while leaving endpoint types and feature semantics in the
owning module.
Modules can also contribute named UI capabilities for explicit extension
points. Capability values must be narrow, typed contracts, not imports from a
@@ -870,6 +1044,16 @@ Decision: templates and reporting are separate modules.
and export targets
- report permissions, report execution history, generated report evidence, and
report-specific retention inputs
Cross-module reports use Core's versioned
`reporting.report_provider.<provider-id>` contract. Source modules own
authorization, parameters, source revisions, effective scope, result schema,
and privacy transforms; Reporting owns discovery, validation, governed
execution, provenance, export history, and the global `/reports` route. The
optional `policy.reporting_governance` capability can only tighten execution,
retention, export, and re-identification-risk handling. Reporting exposes
`reporting.retention` so the Policy-owned retention run can minimize expired
provider results without importing Reporting models.
- downstream export handoff to files, dataflow, connectors, or publication
surfaces
@@ -962,7 +1146,7 @@ from workflow semantics.
- form definitions, schemas, validation rules, field visibility rules,
localization, versioning, admin editing, and reusable form package fragments
`govoplan-forms-runtime` owns, when implemented:
`govoplan-forms-runtime` owns:
- public/internal submissions, drafts, submitted values, validation evidence,
attachment references, submission receipts, and handoff events
@@ -975,6 +1159,16 @@ Boundary:
- Reporting/dataflow may consume submitted data through governed DTOs or
source lifecycle contracts.
Implemented contract:
- Core owns the provider-neutral `FormDefinition`/`FormFieldDefinition` DTOs.
- Forms persists immutable exact definitions and provides `forms.definitions`.
- Forms Runtime resolves that capability, persists revisioned instances and
events, validates draft/final values, and provides
`forms_runtime.service_launcher`.
- Portal delegates exact `<form-id>/<revision>` bindings and never writes either
owner's tables.
### OpenDesk Integration Profile
Tracking: `govoplan-core#195`, `govoplan-connectors#5`,
@@ -1063,6 +1257,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
@@ -1170,6 +1419,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
@@ -1219,6 +1473,9 @@ the same restart/health set after restoring package and database snapshots.
The installer preflight is intentionally conservative:
- maintenance mode must be active;
- the `shared` state profile blocks in-place package mutation; clustered
installations must roll one verified immutable module composition across all
replicas;
- installed module manifests must be compatible with the supported manifest
contract and current core version;
- uninstalling `tenancy`, `access`, or `admin` is blocked;
@@ -1319,6 +1576,22 @@ The first implementation is a platform access gate. It does not replace
database backups, process supervision, migration checks, or external load
balancer maintenance pages.
## Connector Runtime Contract
Core defines provider-neutral connector preview and diagnostic primitives in
`govoplan_core.core.connector_runtime`. The contract keeps optional modules
decoupled: Connectors owns transport, endpoint discovery, retries, and protocol
health; the consuming domain module owns mappings, validation, reconciliation,
and mutations of its records.
Every dry run is bounded and identifies the source revision, source fingerprint,
immutable input hash, effects, and redacted diagnostics. Its summary must match
the returned effect list exactly. An apply token is usable only when the preview
is complete, current, conflict-free, and contains no error diagnostic. Endpoint
URLs never contain credentials; only credential-envelope references cross the
contract. Provider-specific details belong in sanitized provenance rather than
in a shared domain schema.
## Build And Verification
Backend verification from core:
+66
View File
@@ -0,0 +1,66 @@
# Module Lifecycle Recovery
## Migration Revision Namespace
All enabled module migration directories are assembled into one Alembic graph. Revision IDs are therefore global across Core and every module even though each module owns a separate `migrations/versions` directory. Core validates literal revision declarations before constructing the graph and rejects duplicates with both file paths. A module must assign a new globally unique revision ID; reusing another module's ID can otherwise make Alembic treat an unrelated schema change as already applied or report an ancestor/head overlap.
When correcting a collision that has already reached a database, first verify the schema objects that identify which migration actually ran. Rename the unapplied migration, or transactionally translate the corresponding `alembic_version` row when the applied owner is unambiguous. Never add both colliding IDs as heads or blindly stamp the database.
Package changes and live module-graph changes use Core's durable recovery
ledger. The local `install.lock` still prevents duplicate work in one runtime
directory; the database lease `core:module-lifecycle:deployment` is the
deployment-wide authority across API, installer, worker, and scheduler nodes.
## Declared Boundaries
| Operation | Recovery mode | Completion condition |
| --- | --- | --- |
| `module-lifecycle.pre-migration` | compensation | package, WebUI, manifest, and desired-graph evidence match |
| `module-lifecycle.post-migration` | forward recovery | migration tasks, manifests, desired graph, restart, and health are verified |
| `module-retirement.destroy-data` | snapshot restore | a hashed, restore-checked backup exists and retirement state is verified |
| `module-runtime.apply-graph` | compensation | hooks, capability contexts, active graph, and workflow contributions match |
The installer prepares the recovery operation before it captures the database
snapshot. A full database restore therefore retains the prepared operation and
its fence instead of erasing the fact that a mutation was attempted. Backup
artifacts are hashed and sized before any package, migration, or retirement
effect starts.
Every command boundary records the command source and canonical hashes of the
redacted command/result records. Credentials, database URLs, command output,
and package-registry secrets are never copied into recovery evidence.
## Failure And Retry Rules
- A conclusive failure before effects is terminal `failed`.
- A command or compensatable effect that started but did not complete is
`recovery_required`.
- A lost or unexpected outcome after a migration/external boundary is
`outcome_unknown`.
- A verified package/database rollback becomes `recovered`.
- A supervised install becomes `succeeded` only after restart and all configured
health probes succeed.
An unresolved lifecycle operation blocks every later lifecycle mutation on the
same deployment fence, even after its execution lease is released. Operators
must inspect the checkpoint chain and run record, restore or complete the
declared recovery path, and explicitly reconcile the operation. A new install
must not be used as an implicit retry.
Live graph changes use the same fence. A non-migrating hook or registry failure
restores the prior in-process graph and records verified compensation. A failure
after migrations begin remains unresolved because restoring the process-local
registry does not reverse database schema effects.
## Operator Evidence
The installer run record contains the recovery operation id, mode, plan hash,
and current lifecycle status. The Ops recovery view is authoritative for the
durable state and evidence-chain result. Keep both the run directory and the
state-service backup evidence until the operation is terminal and the normal
retention policy permits removal.
Run the module installer rollback drill and recovery-runtime test matrix before
enabling lifecycle mutation in a new deployment. Shared-state deployments must
still use immutable release images; the ledger does not make in-place package
mutation across replicas safe.
+4 -2
View File
@@ -175,8 +175,10 @@ connector or module issue.
queries, untraceable manual transformations.
- MVP test path: publish one report/export as a governed file plus RSS/Atom
entry with checksum, timestamp, and permission check.
- Owner/priority: `govoplan-reporting`, `govoplan-connectors`, possible future
`govoplan-datasources`/`govoplan-dataflow`, Wave 2.
- Owner/priority: `govoplan-reporting`, `govoplan-connectors`,
`govoplan-datasources`, and `govoplan-dataflow`, Wave 2. Reporting owns
presentation/publication, Connectors owns transport, Datasources owns the
governed source/materialization catalogue, and Dataflow owns transformations.
### Public-Sector Protocols And Registries
+26
View File
@@ -0,0 +1,26 @@
# Search event indexing contract
Core defines, but does not implement, the optional Search indexing boundary.
Feature modules register `SearchSourceProvider` implementations for bounded
backfills and live authorization checks. A provider may additionally implement
`SearchEventSourceProvider` to translate a committed `PlatformEvent` into one
or more authoritative `SearchIndexChange` values.
When the Search index-writer capability is active, the platform event worker
uses the durable consumer identity `search.indexing.v1`. It accepts only public
and internal events, passes the outbox delivery key to each event-capable
source, and then advances a bounded batch of queued index changes in the same
worker transaction. Stable change IDs make delivery replay idempotent.
The boundary has three non-negotiable rules:
- a source may emit changes only for its registered module, provider, resource
type, and event tenant;
- Search validates every upsert document before queueing it and rejects secret
metadata keys;
- an index ACL is only a candidate filter. Resources marked for authorization
recheck are returned only after the owning source explicitly allows the
current principal at query time.
Search and its worker remain optional. Core-only startup and feature-module
operation do not require the Search package.
+155
View File
@@ -0,0 +1,155 @@
# State And Recovery Contract
## State Profiles
Core accepts three runtime state profiles:
| Profile | Runtime placement | Durable storage |
| --- | --- | --- |
| `local` | One development process set | Local filesystem is permitted. |
| `host-shared` | Replicas on one host | One host-shared volume plus PostgreSQL and Redis. |
| `shared` | Independent hosts | PostgreSQL, Redis, and S3-compatible object storage. |
All replicas in one installation use one stable
`GOVOPLAN_INSTALLATION_ID`, one immutable module composition, and identical
database, broker, encryption-key, and object-storage bindings. Core rejects
replicas with the `local` profile and rejects `shared` without PostgreSQL,
Redis, S3, and a non-default installation identifier.
`FILE_STORAGE_S3_ENDPOINT_TRUSTED=true` is an explicit deployment trust
declaration for a clean HTTPS S3 origin. It does not authorize a
user-controlled connector endpoint and it is separate from installer-managed
Garage's exact endpoint trust.
## Object Storage
`govoplan_core.core.object_storage` is the shared backend contract for durable
module artifacts. It provides bounded read/write/list/stat/delete operations
for local and S3-compatible storage. Modules own their object-key namespace and
business metadata; Core does not interpret module files.
`stat` and `list_objects` return object size plus a UTC `modified_at` value when
the backend can prove it. Reconciliation and retention code may use that value
for conservative grace periods, but must treat a missing timestamp as
ineligible for automatic deletion rather than guessing an age.
Rules for modules:
- Store only opaque object keys in business records, never local absolute
paths.
- Use node-local directories only for temporary materialization.
- Verify expected size and digest before consuming consequential artifacts.
- If object creation precedes database commit, compensate successfully created
objects on failure.
- If object deletion fails, retain the database reference and report a retryable
failure rather than claiming deletion.
- Define an orphan-inventory strategy for hard process loss between object
creation and metadata commit.
The Files module delegates its backend implementation to this Core contract.
Campaign generated EML artifacts use a Campaign-owned object prefix and are
read by workers through the same shared backend.
## Runtime Nodes And Leases
API and worker incarnations register in `core_runtime_nodes` with role,
software version, module-composition hash, queues, start time, and heartbeat.
The registration identity includes a process incarnation so a stale process
cannot update a replacement's row.
Worker metadata also records the orchestrator pool and declared concurrency.
Every Celery prefork child disposes the SQLAlchemy pool inherited from its
parent and creates a process-local pool before handling work. Deployment
rendering must therefore budget one database pool for the worker parent and
each child. Ops compares active queue ownership, software versions, and the
order-independent module-composition hash with the graph loaded by the API.
Drain is durable operator intent:
- an API enters not-ready state after observing drain;
- a worker cancels queue consumers after observing drain;
- cancellation returns an eligible draining node to active state;
- clean shutdown marks the matching incarnation stopped.
Coordination loss also fails closed. An API reports
`coordination_unavailable` from `/health/ready` until a heartbeat succeeds
again. A worker cancels its local queue consumers on any heartbeat or database
failure and only resumes them after its existing incarnation heartbeats
successfully. It never re-registers from the heartbeat path, so a stale worker
cannot reclaim a node identity from its replacement.
`core_distributed_leases` provides installation/resource uniqueness, expiry,
holder incarnation, and monotonically increasing fencing tokens. Lease expiry
does not make an old process harmless by itself: code performing an effect must
assert its exact fence before commit. `govoplan_core.commands.fenced_run`
renews a lease around a subprocess and terminates the child when the lease is
lost. The deployment profiles use it for the singleton scheduler.
## Migration Ordering
Only `govoplan_core.commands.init_db` mutates schema. On PostgreSQL it acquires
a deterministic installation/track advisory lock before pre-migration tasks,
Alembic, and post-migration tasks. The lock is session-scoped and therefore
released if the migration process dies.
Runtime roles run `govoplan_core.commands.wait_for_database`. It waits until
the database has exactly the configured Core/module Alembic heads and never
upgrades schema. This permits a migration Job and runtime Deployments to be
submitted together while keeping startup fail-closed.
## Recovery Ledger
`govoplan_core.core.recovery` provides a durable operation and evidence
contract. Recovery modes are:
- `atomic`: one database transaction, no external effect;
- `compensation`: explicit inverse actions;
- `snapshot_restore`: separately verified backup reference;
- `forward_recovery`: repair/resume the current version;
- `irreversible`: explicit approval, no automated recovery claim.
Every plan requires verification steps. Mode-specific evidence is mandatory.
Operations bind an idempotency key to a canonical request hash, may bind a
runtime fencing token, and emit append-only hash-chained checkpoints. Backup
and approval references are part of the hashed plan evidence. Every low-level
state transition and checkpoint append revalidates the operation's recorded
fence while holding the operation row lock. Plaintext secrets are rejected
from metadata and evidence.
The state machine makes partial and uncertain outcomes visible. A non-atomic
running operation cannot transition directly to ordinary failure, and success
or recovery requires explicit verified checks. Ops projects states requiring
attention, but module behavior gains this guarantee only after it adopts the
ledger around its own side effects.
## Recovery Boundary
Application/configuration rollback and database rollback are not equivalent.
Once an incompatible migration starts, old code may be unsafe even if its image
is available. Deployment automation must switch to forward recovery unless a
coordinated and verified database/object/key backup is restored.
Core does not create production database backups. The deployment owner must
provide backup, retention, encryption, restore verification, and recovery-point
coordination for PostgreSQL, object storage, and encryption keys. The canonical
operator procedure is documented in
`govoplan/docs/RECOVERY_AND_ROLLBACK_GUARANTEES.md`.
## Verification
Focused contracts are covered by:
```sh
/mnt/DATA/git/govoplan/.venv/bin/python -m pytest -q \
tests/test_object_storage.py \
tests/test_runtime_coordination.py \
tests/test_runtime_agents.py \
tests/test_fenced_run.py \
tests/test_migration_lock.py \
tests/test_wait_for_database.py \
tests/test_recovery_guarantees.py
```
Production acceptance additionally requires multi-node failure and coordinated
restore drills against the actual PostgreSQL, Redis, object-store, ingress, and
secret-provider topology.
+21
View File
@@ -0,0 +1,21 @@
# Tabular Source Preview Contract
Core defines provider-neutral DTOs for optional tabular source providers. A
source declares whether it is live, cached, file-backed, or static; its schema
and immutable fingerprint; structured health; and the exact projection,
pagination, filter, aggregation, and sorting operations that the provider can
push down. Consumers must not infer pushdown support from a provider name.
Every preview request carries independent row, byte, and elapsed-time budgets.
A provider may tighten these values but must return its effective limits,
returned byte count, elapsed milliseconds, truncation state, and structured
diagnostics. Equivalent fields on the Datasources read request and result
preserve that evidence when a live source is consumed through the catalogue.
A row that cannot fit within the byte budget fails explicitly rather than
leaking a partial value. Timeout, stale fingerprint, unavailable source, and
authorization failures remain distinct provider-neutral errors.
Connector health and preview diagnostics must contain no credentials, endpoint
userinfo, row values, or unbounded remote error bodies. A Datasource origin
preserves this contract so registration and staging do not erase source mode,
health, pushdown, or preview-limit evidence.
+28
View File
@@ -0,0 +1,28 @@
# Template And Generated Artifact Capability Contracts
Core defines provider-neutral contracts for optional template libraries and
generated artifact storage. Core does not render templates or store generated
files itself.
## Templates
- `templates.catalog` lists typed, versioned template references and checks a
consumer's available fields, usage, and output format.
- `templates.renderer` accepts a `TemplateRenderRequest` containing pinned
input data and returns immutable render evidence plus an artifact reference.
The DTOs contain identifiers, hashes, plain mappings, and scalar metadata. They
do not expose Template ORM models or require Campaign, Distribution Lists,
Addresses, Reporting, Forms, or Mail.
## Generated Artifacts
`files.artifact_store` accepts a `ManagedArtifactWriteRequest` and returns a
`ManagedArtifactRef`. Producers supply bytes, a safe filename/content type,
idempotency key, and non-secret provenance. Files owns path normalization,
authorization, versions, storage, and download behavior.
Consumers must discover both contracts through the module registry and degrade
only the unavailable path. A template renderer may return a bounded download
when Files is absent. A caller must not infer successful external delivery from
successful rendering or artifact persistence.
+28
View File
@@ -0,0 +1,28 @@
# WebUI Theme Contract
GovOPlaN supports `system`, `light`, and `dark` as persisted user preferences.
`system` follows `prefers-color-scheme` live; it is not resolved permanently at
save time. Core applies the resolved mode through `data-theme` on the document
root and exposes the selected preference through `data-theme-preference`.
## Ownership
- Core owns semantic CSS tokens, native `color-scheme`, preference persistence,
the Settings selector, and the shared shell.
- Modules consume semantic tokens such as `--surface`, `--text`, `--line`, and
the status token families. They may define domain aliases whose values resolve
to shared tokens.
- 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.
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
Campaign, Calendar, Files, and Mail token consumption. The check runs before a
production WebUI build.
+19 -5
View File
@@ -50,6 +50,11 @@ 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 state change the titlebar surface and repeat a quiet status label behind its controls. They must not replace, cover, or intercept the centered global-search surface; an accessible status control remains in the leading titlebar area. | 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 |
## Confirmed Implementation Decisions
@@ -223,6 +228,10 @@ instead of reproducing their behavior.
- `help` content is contextual guidance, not the accessible name. The persisted
`show_inline_help_hints` user preference hides only the `InlineHelp` marker by
applying `ui-hide-help-hints` at the document root.
- Shared action-bearing components accept an optional disabled reason. In
particular, `MailServerSettingsPanel` forwards protocol-specific test
blockers into the shared focusable disabled-action tooltip; modules provide
the domain-specific required field, permission, or in-progress reason.
- A dirty editor registers once with `useUnsavedDraftGuard`. An explicit
Discard button calls `useUnsavedChanges().requestDiscard(afterResolve)`; SPA
navigation uses `useGuardedNavigate` or `requestNavigation`. Both paths show
@@ -272,7 +281,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 +312,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, and explicit busy/clean disabled-action reasons. | Keep bounded; new contributed sections must satisfy the checklist. |
## Impact Index
@@ -339,6 +348,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
+12
View File
@@ -0,0 +1,12 @@
# WebUI Module Package Layout
Core discovers a module contribution from `src/module.ts` when `node_modules`
links directly to a module's `webui` package. Tagged release dependencies are
installed from repository-root packages and expose the same contribution at
`webui/src/module.ts`. The Vite registry accepts both layouts and imports the
contribution descriptor directly so route-level lazy loading is preserved.
A release package is invalid if neither entry exists. The module-permutation CI
matrix builds source-linked and installed release compositions; it must not fall
back to a package root barrel because that would eagerly pull module pages into
the shell bundle.
+402
View File
@@ -663,6 +663,408 @@
"release": "0.1.14",
"squash_policy": "reviewed-manual",
"track": "release"
},
{
"heads": [
{
"owner": "govoplan-notifications",
"revision": "6e2f91ab4c70"
},
{
"owner": "govoplan-poll",
"revision": "6e7f8a9b0c1d"
},
{
"owner": "govoplan-dashboard",
"revision": "7b9d2f4a6c8e"
},
{
"owner": "govoplan-voting",
"revision": "8b9c0d1e2f3a"
},
{
"owner": "govoplan-mail",
"revision": "93b4c5d6e7f8"
},
{
"owner": "govoplan-forms-runtime",
"revision": "a3d5f7b9c1e2"
},
{
"owner": "govoplan-templates",
"revision": "a3f7c9d2e1b4"
},
{
"owner": "govoplan-organizations",
"revision": "a61e4d9c72b8"
},
{
"owner": "govoplan-mandates",
"revision": "a8b1c2d3e4f5"
},
{
"owner": "govoplan-audit",
"revision": "a8d1e4f7b2c5"
},
{
"owner": "govoplan-approvals",
"revision": "a91c4e72b5d8"
},
{
"owner": "govoplan-policy",
"revision": "a9c4e7b2d5f8"
},
{
"owner": "govoplan-idm",
"revision": "b1c2d3e4f5a6"
},
{
"owner": "govoplan-search",
"revision": "b2c3d4e5f607"
},
{
"owner": "govoplan-datasources",
"revision": "b8d2f5a0c3e7"
},
{
"owner": "govoplan-views",
"revision": "b8e4c1f7a2d9"
},
{
"owner": "govoplan-risk-compliance",
"revision": "b9c0d1e2f3a4"
},
{
"owner": "govoplan-services",
"revision": "b9c2d3e4f5a6"
},
{
"owner": "govoplan-parties",
"revision": "c0d3e4f5a6b7"
},
{
"owner": "govoplan-identity-trust",
"revision": "c3f5a7b9d1e2"
},
{
"owner": "govoplan-projects",
"revision": "c4a1e8f2d6b9"
},
{
"owner": "govoplan-addresses",
"revision": "c5d7e8f9a0b1"
},
{
"owner": "govoplan-access",
"revision": "c7e0a3d6f9b2"
},
{
"owner": "govoplan-reporting",
"revision": "c8d5e2f6a9b3"
},
{
"owner": "govoplan-scheduling",
"revision": "c9d4e7f1a2b3"
},
{
"owner": "govoplan-decisions",
"revision": "d1e4f5a6b7c8"
},
{
"owner": "govoplan-calendar",
"revision": "d24e5f607182"
},
{
"owner": "govoplan-committee",
"revision": "d8b9f0a1c2e3"
},
{
"owner": "govoplan-postbox",
"revision": "d8e3f6a9b2c5"
},
{
"owner": "govoplan-campaign",
"revision": "e3c8f4a5b6d7"
},
{
"owner": "govoplan-workflow-engine",
"revision": "e4a1f8c2d7b6"
},
{
"owner": "govoplan-encryption",
"revision": "e5b7c9d1f3a4"
},
{
"owner": "govoplan-dist-lists",
"revision": "e7c3a9d1b5f2"
},
{
"owner": "govoplan-files",
"revision": "f1a2b3c4d5e7"
},
{
"owner": "govoplan-core",
"revision": "f25c9d3e7a01"
},
{
"owner": "govoplan-dataflow",
"revision": "f6c2a9d4e7b1"
},
{
"owner": "govoplan-cases",
"revision": "f6d3a8b1c4e7"
},
{
"owner": "govoplan-connectors",
"revision": "f7c8d9e0a1b2"
}
],
"owner_heads": [
{
"owner": "govoplan-access",
"revisions": [
"c7e0a3d6f9b2"
]
},
{
"owner": "govoplan-addresses",
"revisions": [
"c5d7e8f9a0b1"
]
},
{
"owner": "govoplan-approvals",
"revisions": [
"a91c4e72b5d8"
]
},
{
"owner": "govoplan-audit",
"revisions": [
"a8d1e4f7b2c5"
]
},
{
"owner": "govoplan-calendar",
"revisions": [
"d24e5f607182"
]
},
{
"owner": "govoplan-campaign",
"revisions": [
"e3c8f4a5b6d7"
]
},
{
"owner": "govoplan-cases",
"revisions": [
"f6d3a8b1c4e7"
]
},
{
"owner": "govoplan-committee",
"revisions": [
"d8b9f0a1c2e3"
]
},
{
"owner": "govoplan-connectors",
"revisions": [
"f7c8d9e0a1b2"
]
},
{
"owner": "govoplan-core",
"revisions": [
"f25c9d3e7a01"
]
},
{
"owner": "govoplan-dashboard",
"revisions": [
"7b9d2f4a6c8e"
]
},
{
"owner": "govoplan-dataflow",
"revisions": [
"f6c2a9d4e7b1"
]
},
{
"owner": "govoplan-datasources",
"revisions": [
"b8d2f5a0c3e7"
]
},
{
"owner": "govoplan-decisions",
"revisions": [
"d1e4f5a6b7c8"
]
},
{
"owner": "govoplan-dist-lists",
"revisions": [
"e7c3a9d1b5f2"
]
},
{
"owner": "govoplan-encryption",
"revisions": [
"e5b7c9d1f3a4"
]
},
{
"owner": "govoplan-files",
"revisions": [
"f1a2b3c4d5e7"
]
},
{
"owner": "govoplan-forms",
"revisions": [
"e1f2a3b4c5d6"
]
},
{
"owner": "govoplan-forms-runtime",
"revisions": [
"a3d5f7b9c1e2"
]
},
{
"owner": "govoplan-identity",
"revisions": [
"5c6d7e8f9a10"
]
},
{
"owner": "govoplan-identity-trust",
"revisions": [
"c3f5a7b9d1e2"
]
},
{
"owner": "govoplan-idm",
"revisions": [
"b1c2d3e4f5a6"
]
},
{
"owner": "govoplan-mail",
"revisions": [
"93b4c5d6e7f8"
]
},
{
"owner": "govoplan-mandates",
"revisions": [
"a8b1c2d3e4f5"
]
},
{
"owner": "govoplan-notifications",
"revisions": [
"6e2f91ab4c70"
]
},
{
"owner": "govoplan-organizations",
"revisions": [
"a61e4d9c72b8"
]
},
{
"owner": "govoplan-parties",
"revisions": [
"c0d3e4f5a6b7"
]
},
{
"owner": "govoplan-policy",
"revisions": [
"a9c4e7b2d5f8"
]
},
{
"owner": "govoplan-poll",
"revisions": [
"6e7f8a9b0c1d"
]
},
{
"owner": "govoplan-postbox",
"revisions": [
"d8e3f6a9b2c5"
]
},
{
"owner": "govoplan-projects",
"revisions": [
"c4a1e8f2d6b9"
]
},
{
"owner": "govoplan-reporting",
"revisions": [
"c8d5e2f6a9b3"
]
},
{
"owner": "govoplan-risk-compliance",
"revisions": [
"b9c0d1e2f3a4"
]
},
{
"owner": "govoplan-scheduling",
"revisions": [
"c9d4e7f1a2b3"
]
},
{
"owner": "govoplan-search",
"revisions": [
"b2c3d4e5f607"
]
},
{
"owner": "govoplan-services",
"revisions": [
"b9c2d3e4f5a6"
]
},
{
"owner": "govoplan-templates",
"revisions": [
"a3f7c9d2e1b4"
]
},
{
"owner": "govoplan-views",
"revisions": [
"b8e4c1f7a2d9"
]
},
{
"owner": "govoplan-voting",
"revisions": [
"8b9c0d1e2f3a"
]
},
{
"owner": "govoplan-workflow-engine",
"revisions": [
"e4a1f8c2d7b6"
]
}
],
"recorded_at": "2026-08-04T13:09:52Z",
"release": "0.1.15",
"squash_policy": "reviewed-manual",
"track": "release"
}
],
"version": 1
+3 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-core"
version = "0.1.14"
version = "0.1.15"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md"
requires-python = ">=3.12"
@@ -19,6 +19,7 @@ dependencies = [
"celery>=5,<6",
"redis>=5,<6",
"alembic>=1,<2",
"boto3>=1.34,<2",
]
[tool.setuptools.packages.find]
@@ -36,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"
+7
View File
@@ -73,6 +73,12 @@ class SwitchTenantRequest(BaseModel):
tenant_id: str
class SwitchActingContextRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
assignment_id: str | None = Field(default=None, max_length=36)
class TenantInfo(BaseModel):
id: str
slug: str
@@ -178,6 +184,7 @@ class PrincipalContextInfo(BaseModel):
api_key_id: str | None = None
session_id: str | None = None
service_account_id: str | None = None
acting_assignment_id: str | None = None
acting_for_account_id: str | None = None
email: str | None = None
display_name: str | None = None
+26 -1
View File
@@ -18,6 +18,7 @@ from govoplan_core.core.events import (
normalize_trace_id,
emit_platform_event,
)
from govoplan_core.core.institutional import GovernedContextEnvelope
from govoplan_core.core.runtime import get_registry
from govoplan_core.privacy.retention import sanitize_audit_details_for_policy
from govoplan_core.security.redaction import redact_secret_values
@@ -176,6 +177,16 @@ def _publish_audit_platform_event(
item: AuditRecordRef,
) -> None:
trace = _compact_trace(item.details.get("_trace") if isinstance(item.details, Mapping) else None)
raw_context = (
item.details.get("_institutional_context")
if isinstance(item.details, Mapping)
else None
)
institutional_context = (
GovernedContextEnvelope.from_mapping(raw_context)
if isinstance(raw_context, Mapping)
else None
)
emit_platform_event(
session,
PlatformEvent(
@@ -194,6 +205,7 @@ def _publish_audit_platform_event(
tenant=EventTenantRef(id=item.tenant_id) if item.tenant_id else None,
resource=EventObjectRef(type=item.object_type, id=item.object_id) if item.object_type else None,
classification="internal",
institutional_context=institutional_context,
)
)
@@ -211,6 +223,7 @@ def audit_event(
details: dict[str, Any] | None = None,
correlation_id: str | None = None,
causation_id: str | None = None,
institutional_context: GovernedContextEnvelope | None = None,
commit: bool = False,
) -> AuditRecordRef:
"""Persist one audit event.
@@ -223,8 +236,17 @@ def audit_event(
if scope not in {"tenant", "system"}:
raise ValueError(f"Unsupported audit scope: {scope}")
if (
institutional_context is not None
and tenant_id is not None
and institutional_context.tenant_id != tenant_id
):
raise ValueError("Audit institutional context belongs to another tenant")
raw_details = dict(details or {})
if institutional_context is not None:
raw_details["_institutional_context"] = institutional_context.to_dict()
traced_details, trace = _trace_details(
_sanitize_details(details or {}),
_sanitize_details(raw_details),
correlation_id=correlation_id,
causation_id=causation_id,
)
@@ -242,6 +264,7 @@ def audit_event(
api_key_id=api_key_id,
resource_type=object_type,
resource_id=object_id,
institutional_context=institutional_context,
details=stored_details,
))
record_change(
@@ -273,6 +296,7 @@ def audit_from_principal(
details: dict[str, Any] | None = None,
correlation_id: str | None = None,
causation_id: str | None = None,
institutional_context: GovernedContextEnvelope | None = None,
commit: bool = False,
) -> AuditRecordRef:
return audit_event(
@@ -287,5 +311,6 @@ def audit_from_principal(
details=details,
correlation_id=correlation_id,
causation_id=causation_id,
institutional_context=institutional_context,
commit=commit,
)
+8
View File
@@ -81,6 +81,10 @@ class ApiPrincipal:
def acting_for_account_id(self) -> str | None:
return self.principal.acting_for_account_id
@property
def acting_assignment_id(self) -> str | None:
return self.principal.acting_assignment_id
@property
def auth_method(self) -> str:
return self.principal.auth_method
@@ -131,6 +135,9 @@ def get_api_principal(
authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
) -> ApiPrincipal:
cached = getattr(request.state, "govoplan_api_principal", None)
if isinstance(cached, ApiPrincipal):
return cached
principal = _api_principal_provider_from_request(request).resolve_api_principal(
request,
session,
@@ -139,6 +146,7 @@ def get_api_principal(
)
if not isinstance(principal, ApiPrincipal):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid API principal")
request.state.govoplan_api_principal = principal
return principal
File diff suppressed because it is too large Load Diff
+199
View File
@@ -0,0 +1,199 @@
from __future__ import annotations
import argparse
from importlib.metadata import PackageNotFoundError, version
import signal
import subprocess
import sys
import threading
import time
from typing import Sequence
from govoplan_core.core.runtime_coordination import (
LeaseClaim,
acquire_lease,
release_lease,
renew_lease,
runtime_identity,
)
from govoplan_core.db.session import configure_database, get_database
from govoplan_core.settings import settings
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Run one process while holding a database-fenced deployment lease."
)
parser.add_argument(
"--resource", required=True, help="Stable cluster-wide lease key."
)
parser.add_argument("--ttl-seconds", type=int, default=60)
parser.add_argument("--renew-seconds", type=int, default=15)
parser.add_argument("--wait-seconds", type=int, default=0)
parser.add_argument("command", nargs=argparse.REMAINDER)
return parser
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
command = list(args.command)
if command and command[0] == "--":
command.pop(0)
if not command:
raise SystemExit("fenced-run requires a command after --")
if args.ttl_seconds < 10:
raise SystemExit("--ttl-seconds must be at least 10")
if args.renew_seconds < 2 or args.renew_seconds * 2 >= args.ttl_seconds:
raise SystemExit("--renew-seconds must be less than half the lease TTL")
configure_database(settings.database_url)
identity = runtime_identity(
settings,
software_version=_core_version(),
role=str(settings.runtime_role or "deployment"),
)
claim = _wait_for_lease(
resource=args.resource,
identity=identity,
ttl_seconds=args.ttl_seconds,
wait_seconds=max(0, args.wait_seconds),
)
if claim is None:
print(
f"lease unavailable: {args.resource}",
file=sys.stderr,
)
return 75
process = subprocess.Popen(command) # noqa: S603 - argv is an operator-owned container command
stop = threading.Event()
fence_lost = threading.Event()
renewer = threading.Thread(
target=_renew_loop,
kwargs={
"claim": claim,
"ttl_seconds": args.ttl_seconds,
"renew_seconds": args.renew_seconds,
"stop": stop,
"fence_lost": fence_lost,
"process": process,
},
daemon=True,
name=f"govoplan-fence:{args.resource}",
)
renewer.start()
previous_handlers = _forward_signals(process)
try:
return_code = process.wait()
finally:
stop.set()
renewer.join(timeout=args.renew_seconds + 2)
_restore_signals(previous_handlers)
_release(claim)
if fence_lost.is_set():
return 74
return int(return_code)
def _wait_for_lease(
*,
resource: str,
identity,
ttl_seconds: int,
wait_seconds: int,
) -> LeaseClaim | None:
deadline = time.monotonic() + wait_seconds
while True:
with get_database().SessionLocal() as session:
claim = acquire_lease(
session,
installation_id=identity.installation_id,
resource_key=resource,
holder_node_id=identity.node_id,
holder_incarnation=identity.incarnation,
ttl_seconds=ttl_seconds,
metadata={"role": identity.role},
)
session.commit()
if claim is not None or time.monotonic() >= deadline:
return claim
time.sleep(min(2, max(0.1, deadline - time.monotonic())))
def _renew_loop(
*,
claim: LeaseClaim,
ttl_seconds: int,
renew_seconds: int,
stop: threading.Event,
fence_lost: threading.Event,
process: subprocess.Popen[bytes],
) -> None:
active_claim = claim
while not stop.wait(renew_seconds):
try:
with get_database().SessionLocal() as session:
active_claim = renew_lease(
session,
active_claim,
ttl_seconds=ttl_seconds,
)
session.commit()
except Exception: # noqa: BLE001 - any renewal failure loses authority
fence_lost.set()
_terminate_process(process)
return
def _terminate_process(
process: subprocess.Popen[bytes],
*,
timeout_seconds: float = 5.0,
) -> None:
if process.poll() is not None:
return
process.terminate()
try:
process.wait(timeout=timeout_seconds)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=timeout_seconds)
def _release(claim: LeaseClaim) -> None:
try:
with get_database().SessionLocal() as session:
release_lease(session, claim)
session.commit()
except Exception: # noqa: BLE001 - authority is already lost; release is best effort
return
def _forward_signals(
process: subprocess.Popen[bytes],
) -> dict[int, signal.Handlers]:
previous: dict[int, signal.Handlers] = {}
def forward(signum, _frame) -> None:
if process.poll() is None:
process.send_signal(signum)
for signum in (signal.SIGTERM, signal.SIGINT):
previous[signum] = signal.getsignal(signum)
signal.signal(signum, forward)
return previous
def _restore_signals(previous: dict[int, signal.Handlers]) -> None:
for signum, handler in previous.items():
signal.signal(signum, handler)
def _core_version() -> str:
try:
return version("govoplan-core")
except PackageNotFoundError:
return "development"
if __name__ == "__main__":
raise SystemExit(main())
+233
View File
@@ -0,0 +1,233 @@
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import stat
from typing import Any
from govoplan_core.core.access import (
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER,
FirstAdminProvisioner,
)
from govoplan_core.core.first_admin import (
FirstAdminEnrollmentError,
first_admin_enrollment_status,
issue_first_admin_credential,
)
from govoplan_core.core.module_management import (
load_startup_enabled_modules,
startup_candidate_module_ids,
)
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.runtime import configure_runtime
from govoplan_core.db.session import configure_database, get_database
from govoplan_core.server.registry import (
available_module_manifests,
build_platform_registry,
)
from govoplan_core.settings import settings
def main() -> None:
parser = argparse.ArgumentParser(
description="Manage the single-use production first-administrator credential",
)
parser.add_argument(
"command",
choices=("status", "issue", "recover"),
help="Inspect readiness, issue the initial credential, or rotate lost/expired material.",
)
parser.add_argument("--database-url", default=settings.database_url)
parser.add_argument("--installation-id", default=settings.installation_id)
parser.add_argument(
"--output",
type=Path,
default=Path(settings.first_admin_enrollment_file),
help="Root-readable/equivalent JSON credential artifact.",
)
parser.add_argument(
"--ttl-seconds",
type=int,
default=settings.first_admin_enrollment_ttl_seconds,
)
parser.add_argument(
"--reason",
default=None,
help="Audited local-operator reason for issue or recovery.",
)
args = parser.parse_args()
configure_database(args.database_url)
provisioner = _configure_first_admin_provisioner()
with get_database().SessionLocal() as session:
if args.command == "status":
enrollment = first_admin_enrollment_status(
session,
installation_id=args.installation_id,
provisioner=provisioner,
)
print(
json.dumps(
{
"installation_id": args.installation_id,
"enrollment_required": enrollment.enrollment_required,
"credential_active": enrollment.credential_active,
"state": enrollment.state,
"generation": enrollment.generation,
"expires_at": (
enrollment.expires_at.isoformat()
if enrollment.expires_at is not None
else None
),
"readiness": enrollment.readiness,
},
indent=2,
sort_keys=True,
)
)
return
reason = args.reason or (
"initial production administrator enrollment"
if args.command == "issue"
else "local operator recovery of first-administrator enrollment"
)
try:
credential = issue_first_admin_credential(
session,
installation_id=args.installation_id,
provisioner=provisioner,
ttl_seconds=args.ttl_seconds,
reason=reason,
replace_active=args.command == "recover",
)
payload = {
"schema_version": 1,
"installation_id": args.installation_id,
"endpoint": "/api/v1/bootstrap/first-admin",
"header": "X-GovOPlaN-Enrollment-Token",
"enrollment_token": credential.secret,
"fingerprint": credential.fingerprint,
"generation": credential.generation,
"expires_at": credential.expires_at.isoformat(),
}
previous = _secure_file_snapshot(args.output)
_write_private_json(args.output, payload)
try:
session.commit()
except Exception:
session.rollback()
_restore_secure_file(args.output, previous)
raise
except FirstAdminEnrollmentError as exc:
session.rollback()
parser.error(str(exc))
print(f"First-administrator credential written to {args.output}")
print(f"Fingerprint: {credential.fingerprint}")
print(f"Expires: {credential.expires_at.isoformat()}")
print("The secret was not printed. Read it from the restricted artifact on the host.")
def _configure_first_admin_provisioner() -> FirstAdminProvisioner:
raw_enabled = load_startup_enabled_modules(settings.enabled_modules)
candidates = startup_candidate_module_ids(settings.enabled_modules, raw_enabled)
available = available_module_manifests(
enabled_modules=candidates,
ignore_load_errors=True,
)
enabled = load_startup_enabled_modules(
settings.enabled_modules,
available=available,
)
registry = build_platform_registry(enabled)
context = ModuleContext(registry=registry, settings=settings)
registry.configure_capability_context(context)
configure_runtime(context)
if not registry.has_capability(CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER):
raise RuntimeError(
"Install and enable the Access module before issuing a first-administrator credential."
)
capability = registry.require_capability(CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER)
if not isinstance(capability, FirstAdminProvisioner):
raise RuntimeError("The Access first-administrator capability is invalid.")
return capability
def _secure_file_snapshot(path: Path) -> tuple[bytes, int] | None:
try:
metadata = path.lstat()
except FileNotFoundError:
return None
if not stat.S_ISREG(metadata.st_mode):
raise RuntimeError(f"Refusing to replace non-regular credential artifact: {path}")
if metadata.st_uid != os.geteuid():
raise RuntimeError(f"Credential artifact is not owned by the current operator: {path}")
if stat.S_IMODE(metadata.st_mode) & 0o077:
raise RuntimeError(f"Credential artifact permissions are too broad: {path}")
return path.read_bytes(), stat.S_IMODE(metadata.st_mode)
def _write_private_json(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
descriptor = os.open(temporary, flags, 0o600)
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
json.dump(payload, stream, indent=2, sort_keys=True)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
os.chmod(path, 0o600)
_fsync_directory(path.parent)
except Exception:
try:
temporary.unlink()
except FileNotFoundError:
pass
raise
def _restore_secure_file(path: Path, snapshot: tuple[bytes, int] | None) -> None:
if snapshot is None:
try:
path.unlink()
except FileNotFoundError:
return
return
content, mode = snapshot
temporary = path.with_name(f".{path.name}.{os.getpid()}.restore")
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
os.chmod(path, mode)
_fsync_directory(path.parent)
finally:
try:
temporary.unlink()
except FileNotFoundError:
pass
def _fsync_directory(path: Path) -> None:
if not hasattr(os, "O_DIRECTORY"):
return
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
if __name__ == "__main__":
main()
+32 -19
View File
@@ -12,6 +12,7 @@ from govoplan_core.db.migrations import (
migrate_database,
run_registered_module_migration_tasks,
)
from govoplan_core.db.migration_lock import deployment_migration_lock
from govoplan_core.db.session import configure_database, get_database
from govoplan_core.settings import settings
@@ -28,6 +29,12 @@ def main() -> None:
parser.add_argument("--enabled-module", action="append", default=[], help="Target enabled module id used to discover module migrations; may be repeated.")
parser.add_argument("--migration-module", action="append", default=[], help="Module id whose migration heads should be upgraded in this order before final heads.")
parser.add_argument("--migration-task-record-output", type=Path, help="Write executed module migration task records to this JSON file.")
parser.add_argument(
"--migration-lock-timeout-seconds",
type=float,
default=900.0,
help="Maximum wait for the deployment-wide PostgreSQL advisory lock.",
)
parser.add_argument("--with-dev-data", action="store_true", help="Create default tenant/user/roles and a development API key")
parser.add_argument("--dev-api-key", default=settings.dev_bootstrap_api_key, help="Development API key secret to create")
args = parser.parse_args()
@@ -37,26 +44,32 @@ def main() -> None:
migration_order = tuple(args.migration_module) if args.migration_module else None
task_records: list[dict[str, object]] = []
try:
_run_migration_tasks(
task_records,
database_url=args.database_url,
enabled_modules=enabled_modules,
migration_order=migration_order,
phases=PRE_MIGRATION_TASK_PHASES,
)
migration = migrate_database(
database_url=args.database_url,
enabled_modules=enabled_modules,
migration_module_order=migration_order,
with deployment_migration_lock(
args.database_url,
installation_id=settings.installation_id,
migration_track=args.migration_track,
)
_run_migration_tasks(
task_records,
database_url=args.database_url,
enabled_modules=enabled_modules,
migration_order=migration_order,
phases=POST_MIGRATION_TASK_PHASES,
)
timeout_seconds=args.migration_lock_timeout_seconds,
):
_run_migration_tasks(
task_records,
database_url=args.database_url,
enabled_modules=enabled_modules,
migration_order=migration_order,
phases=PRE_MIGRATION_TASK_PHASES,
)
migration = migrate_database(
database_url=args.database_url,
enabled_modules=enabled_modules,
migration_module_order=migration_order,
migration_track=args.migration_track,
)
_run_migration_tasks(
task_records,
database_url=args.database_url,
enabled_modules=enabled_modules,
migration_order=migration_order,
phases=POST_MIGRATION_TASK_PHASES,
)
finally:
if args.migration_task_record_output:
args.migration_task_record_output.parent.mkdir(parents=True, exist_ok=True)
@@ -1,6 +1,7 @@
from __future__ import annotations
import argparse
from importlib.metadata import PackageNotFoundError, version
import json
from pathlib import Path
import sys
@@ -33,6 +34,10 @@ from govoplan_core.core.module_installer_notifications import (
installer_notification_priority,
installer_notification_subject,
)
from govoplan_core.core.runtime_coordination import (
bind_process_runtime_identity,
runtime_identity,
)
from govoplan_core.core.module_license import issue_module_license, module_license_diagnostics
from govoplan_core.core.module_package_catalog import sign_module_package_catalog, validate_module_package_catalog
from govoplan_core.core.module_management import (
@@ -107,11 +112,27 @@ def _build_parser() -> argparse.ArgumentParser:
def main() -> int:
args = _build_parser().parse_args()
runtime_dir = args.runtime_dir or default_installer_runtime_dir(args.database_url)
bind_process_runtime_identity(
runtime_identity(
settings,
software_version=_core_version(),
role="installer",
)
)
try:
return _dispatch_command(args=args, runtime_dir=runtime_dir)
except ModuleInstallerError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
finally:
bind_process_runtime_identity(None)
def _core_version() -> str:
try:
return version("govoplan-core")
except PackageNotFoundError:
return "development"
def _dispatch_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
@@ -0,0 +1,61 @@
from __future__ import annotations
import argparse
import sys
import time
from govoplan_core.db.migrations import (
configured_migration_heads,
database_migration_heads,
)
from govoplan_core.settings import settings
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Wait until the database is at this release's migration heads."
)
parser.add_argument("--database-url", default=settings.database_url)
parser.add_argument(
"--migration-track",
default=settings.migration_track,
choices=("release", "dev"),
)
parser.add_argument("--timeout-seconds", type=float, default=900.0)
parser.add_argument("--poll-seconds", type=float, default=2.0)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.timeout_seconds < 0 or args.poll_seconds <= 0:
raise SystemExit("timeouts must be non-negative and polling must be positive")
expected = configured_migration_heads(
args.database_url,
migration_track=args.migration_track,
)
deadline = time.monotonic() + args.timeout_seconds
last_error = ""
while True:
try:
actual = database_migration_heads(args.database_url)
if actual == expected:
print("Database migration heads are ready: " + ",".join(actual))
return 0
last_error = (
f"database heads={','.join(actual) or '<none>'}; "
f"expected={','.join(expected) or '<none>'}"
)
except Exception as exc: # noqa: BLE001 - connection may become ready later
last_error = f"{type(exc).__name__}: {exc}"
if time.monotonic() >= deadline:
print(
"Database did not reach configured migration heads: " + last_error,
file=sys.stderr,
)
return 75
time.sleep(min(args.poll_seconds, max(0.05, deadline - time.monotonic())))
if __name__ == "__main__":
raise SystemExit(main())
+39
View File
@@ -6,6 +6,7 @@ from datetime import datetime
from typing import Literal, Protocol, cast, runtime_checkable
from govoplan_core.core.modules import AccessDecision
from govoplan_core.core.institutional import GovernedContextEnvelope
ACCESS_MODULE_ID = "access"
@@ -20,6 +21,7 @@ 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_TENANCY_TENANT_RESOLVER = "tenancy.tenantResolver"
@@ -44,6 +46,7 @@ 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_TENANCY_TENANT_RESOLVER,
@@ -125,6 +128,7 @@ class PrincipalRef:
api_key_id: str | None = None
session_id: str | None = None
service_account_id: str | None = None
acting_assignment_id: str | None = None
acting_for_account_id: str | None = None
email: str | None = None
display_name: str | None = None
@@ -144,6 +148,7 @@ class PrincipalRef:
"api_key_id": self.api_key_id,
"session_id": self.session_id,
"service_account_id": self.service_account_id,
"acting_assignment_id": self.acting_assignment_id,
"acting_for_account_id": self.acting_for_account_id,
"email": self.email,
"display_name": self.display_name,
@@ -165,6 +170,7 @@ class PrincipalRef:
api_key_id=_optional_str(value.get("api_key_id")),
session_id=_optional_str(value.get("session_id")),
service_account_id=_optional_str(value.get("service_account_id")),
acting_assignment_id=_optional_str(value.get("acting_assignment_id")),
acting_for_account_id=_optional_str(value.get("acting_for_account_id")),
email=_optional_str(value.get("email")),
display_name=_optional_str(value.get("display_name")),
@@ -338,6 +344,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
@@ -374,6 +393,7 @@ class AuditEvent:
occurred_at: datetime | None = None
correlation_id: str | None = None
causation_id: str | None = None
institutional_context: GovernedContextEnvelope | None = None
details: Mapping[str, object] = field(default_factory=dict)
@@ -574,6 +594,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]:
+207
View File
@@ -0,0 +1,207 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
CAPABILITY_APPROVAL_REQUESTS = "approvals.requests"
class ApprovalCapabilityError(ValueError):
"""Stable error raised by Approval capability implementations."""
@dataclass(frozen=True, slots=True)
class ApprovalActorSelector:
kind: str
value: str
label: str | None = None
@dataclass(frozen=True, slots=True)
class ApprovalStepDefinition:
key: str
label: str
selectors: tuple[ApprovalActorSelector, ...]
required_approvals: int = 1
rejection_policy: str = "fail_fast"
due_at: datetime | None = None
signature_required: bool = False
forbidden_evidence_roles: tuple[str, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ApprovalRequestCreateCommand:
title: str
subject_module: str
subject_type: str
subject_id: str
subject_version: str | None
subject_digest: str
steps: tuple[ApprovalStepDefinition, ...]
description: str | None = None
separation_of_duties: bool = True
unique_actors_across_steps: bool = False
expires_at: datetime | None = None
policy_refs: tuple[str, ...] = ()
evidence_actors: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
template_id: str | None = None
template_revision: int | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ApprovalTemplateCreateCommand:
key: str
title: str
steps: tuple[ApprovalStepDefinition, ...]
description: str | None = None
separation_of_duties: bool = True
unique_actors_across_steps: bool = False
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ApprovalTemplateRef:
id: str
key: str
revision: int
state: str
content_sha256: str
@dataclass(frozen=True, slots=True)
class ApprovalDecisionCommand:
outcome: str
reason: str
expected_revision: int
idempotency_key: str
delegated_for_account_id: str | None = None
signature_ref: Mapping[str, object] | None = None
@dataclass(frozen=True, slots=True)
class ApprovalRequestRef:
id: str
revision: int
state: str
current_step_key: str | None = None
@dataclass(frozen=True, slots=True)
class ApprovalDecisionReceipt:
request_id: str
revision: int
state: str
step_key: str
outcome: str
actor_id: str
recorded_at: datetime
receipt_sha256: str
authority_provenance: Mapping[str, object] = field(default_factory=dict)
replayed: bool = False
@dataclass(frozen=True, slots=True)
class ApprovalCheck:
request_id: str
revision: int
state: str
approved: bool
subject_module: str
subject_type: str
subject_id: str
subject_version: str | None
subject_digest: str
completed_at: datetime | None = None
@runtime_checkable
class ApprovalRequestProvider(Protocol):
def create_template(
self,
session: object,
principal: object,
*,
command: ApprovalTemplateCreateCommand,
idempotency_key: str,
) -> ApprovalTemplateRef: ...
def revise_template(
self,
session: object,
principal: object,
*,
template_id: str,
command: ApprovalTemplateCreateCommand,
expected_revision: int,
idempotency_key: str,
) -> ApprovalTemplateRef: ...
def publish_template(
self,
session: object,
principal: object,
*,
template_id: str,
expected_revision: int,
idempotency_key: str,
) -> ApprovalTemplateRef: ...
def create_request(
self,
session: object,
principal: object,
*,
command: ApprovalRequestCreateCommand,
idempotency_key: str,
) -> ApprovalRequestRef: ...
def get_request(
self,
session: object,
principal: object,
*,
request_id: str,
) -> Mapping[str, object] | None: ...
def decide(
self,
session: object,
principal: object,
*,
request_id: str,
command: ApprovalDecisionCommand,
) -> ApprovalDecisionReceipt: ...
def check_approved(
self,
session: object,
principal: object,
*,
request_id: str,
subject_module: str,
subject_type: str,
subject_id: str,
subject_version: str | None,
subject_digest: str,
) -> ApprovalCheck: ...
__all__ = [
"ApprovalActorSelector",
"ApprovalCapabilityError",
"ApprovalCheck",
"ApprovalDecisionCommand",
"ApprovalDecisionReceipt",
"ApprovalRequestCreateCommand",
"ApprovalRequestProvider",
"ApprovalRequestRef",
"ApprovalStepDefinition",
"ApprovalTemplateCreateCommand",
"ApprovalTemplateRef",
"CAPABILITY_APPROVAL_REQUESTS",
]
+26
View File
@@ -8,6 +8,7 @@ from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.access import (
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
)
from govoplan_core.core.institutional import GovernedContextEnvelope
AutomationInvocationKind = Literal[
"manual",
@@ -30,6 +31,13 @@ ActionReversibility = Literal[
"corrective_only",
"irreversible",
]
ActionRecoveryMode = Literal[
"atomic",
"compensation",
"snapshot_restore",
"forward_recovery",
"irreversible",
]
ActionExecutionState = Literal[
"pending",
"running",
@@ -86,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:
@@ -95,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)
@@ -110,6 +134,7 @@ class ActionExecutionRequest:
input: Mapping[str, object]
idempotency_key: str
invocation: AutomationInvocation
institutional_context: GovernedContextEnvelope | None = None
actor_ref: str | None = None
preview_ref: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@@ -382,6 +407,7 @@ __all__ = [
"AutomationPrincipalResolution",
"AutomationSubjectKind",
"ActionPreview",
"ActionRecoveryMode",
"ActionReversibility",
"ActionRiskLevel",
"EffectDefinition",
+218
View File
@@ -8,6 +8,8 @@ from typing import Protocol, runtime_checkable
CAPABILITY_CALENDAR_SCHEDULING = "calendar.scheduling"
CAPABILITY_CALENDAR_OUTBOX = "calendar.outbox"
CAPABILITY_CALENDAR_INVITATIONS = "calendar.invitations"
CAPABILITY_CALENDAR_EXTERNAL_PROFILES = "calendar.externalProfiles"
CALENDAR_AVAILABILITY_READ_SCOPE = "calendar:availability:read"
CALENDAR_EVENT_WRITE_SCOPE = "calendar:event:write"
@@ -43,6 +45,94 @@ class CalendarEventRef:
outbox_operation_id: str | None = None
@dataclass(frozen=True, slots=True)
class CalendarInvitationAttendeeRequest:
address: str
name: str | None = None
role: str = "REQ-PARTICIPANT"
participation_status: str = "NEEDS-ACTION"
rsvp: bool = True
@dataclass(frozen=True, slots=True)
class CalendarInvitationRequest:
correlation_id: str
source_module: str
source_resource_type: str
source_resource_id: str | None
summary: str
start_at: datetime
attendees: tuple[CalendarInvitationAttendeeRequest, ...]
calendar_id: str | None = None
description: str | None = None
location: str | None = None
end_at: datetime | None = None
timezone: str | None = None
organizer: Mapping[str, object] | None = None
classification: str = "PUBLIC"
categories: tuple[str, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class CalendarInvitationRef:
event_id: str
calendar_id: str
uid: str
correlation_id: str
source_module: str
source_resource_type: str
source_resource_id: str | None
attendees: tuple[Mapping[str, object], ...] = ()
external_state: str = "local"
outbox_operation_id: str | None = None
reply_ingress: str = "capability"
recurrence_supported: bool = False
degraded_reasons: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class CalendarInvitationCalendarRef:
id: str
name: str
color: str | None = None
timezone: str = "UTC"
source_kind: str = "local"
writable: bool = True
@dataclass(frozen=True, slots=True)
class CalendarExternalProfileRequest:
"""Connector-neutral request for a Calendar-owned external profile."""
profile_kind: str
calendar_id: str
endpoint_url: str
display_name: str | None = None
auth_type: str = "none"
username: str | None = None
credential_ref: str | None = None
sync_enabled: bool = True
sync_interval_seconds: int = 900
sync_direction: str = "two_way"
conflict_policy: str = "etag"
connector_profile_ref: str | None = None
identity_mapping_ref: str | None = None
resource_calendar_ref: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class CalendarExternalProfileRef:
source_id: str
calendar_id: str
profile_kind: str
transport_kind: str
connector_profile_ref: str | None = None
identity_mapping_ref: str | None = None
resource_calendar_ref: str | None = None
@runtime_checkable
class CalendarSchedulingProvider(Protocol):
def list_freebusy(
@@ -80,6 +170,108 @@ class CalendarOutboxProvider(Protocol):
) -> Mapping[str, object]:
...
@runtime_checkable
class CalendarInvitationProvider(Protocol):
"""Correlation-aware invitation boundary for Campaign and Mail adapters."""
def list_calendars(
self,
session: object,
*,
tenant_id: str,
user_id: str | None = None,
group_ids: Sequence[str] = (),
can_admin: bool = False,
) -> Sequence[CalendarInvitationCalendarRef]:
...
def render_invitation(self, request: CalendarInvitationRequest) -> str:
...
def upsert_invitation(
self,
session: object,
*,
tenant_id: str,
user_id: str | None,
request: CalendarInvitationRequest,
) -> CalendarInvitationRef:
...
def get_invitation(
self,
session: object,
*,
tenant_id: str,
correlation_id: str,
) -> CalendarInvitationRef | None:
...
def get_invitations(
self,
session: object,
*,
tenant_id: str,
correlation_ids: Sequence[str],
) -> Mapping[str, CalendarInvitationRef]:
...
def summarize_invitations(
self,
session: object,
*,
tenant_id: str,
source_module: str,
source_resource_type: str,
source_resource_id: str | None,
) -> Mapping[str, object]:
...
def record_response(
self,
session: object,
*,
tenant_id: str,
attendee_address: str,
participation_status: str,
correlation_id: str | None = None,
uid: str | None = None,
responded_at: datetime | None = None,
evidence: Mapping[str, object] | None = None,
) -> CalendarInvitationRef:
...
def record_icalendar_reply(
self,
session: object,
*,
tenant_id: str,
icalendar: str,
received_at: datetime | None = None,
evidence: Mapping[str, object] | None = None,
) -> Sequence[CalendarInvitationRef]:
...
@runtime_checkable
class CalendarExternalProfileProvider(Protocol):
"""Optional connector route for Calendar-owned groupware adapters."""
def supported_profiles(self) -> Sequence[Mapping[str, object]]:
...
def configure_profile(
self,
session: object,
*,
tenant_id: str,
user_id: str | None,
request: CalendarExternalProfileRequest,
) -> CalendarExternalProfileRef:
...
def calendar_scheduling_provider(registry: object | None) -> CalendarSchedulingProvider | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
@@ -96,3 +288,29 @@ def calendar_outbox_provider(registry: object | None) -> CalendarOutboxProvider
return None
capability = registry.capability(CAPABILITY_CALENDAR_OUTBOX)
return capability if isinstance(capability, CalendarOutboxProvider) else None
def calendar_invitation_provider(
registry: object | None,
) -> CalendarInvitationProvider | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_CALENDAR_INVITATIONS):
return None
capability = registry.capability(CAPABILITY_CALENDAR_INVITATIONS)
return capability if isinstance(capability, CalendarInvitationProvider) else None
def calendar_external_profile_provider(
registry: object | None,
) -> CalendarExternalProfileProvider | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_CALENDAR_EXTERNAL_PROFILES):
return None
capability = registry.capability(CAPABILITY_CALENDAR_EXTERNAL_PROFILES)
return (
capability
if isinstance(capability, CalendarExternalProfileProvider)
else None
)
+3
View File
@@ -95,6 +95,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]:
...
+564 -29
View File
@@ -3,11 +3,11 @@ from __future__ import annotations
import base64
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
import json
import os
from typing import Any, Literal, Protocol, runtime_checkable
import re
from typing import Any, Literal, Protocol, cast, runtime_checkable
from govoplan_core.core.module_package_catalog import (
_canonical_catalog_bytes,
@@ -20,6 +20,13 @@ from govoplan_core.core.module_package_catalog import (
_is_http_url,
_load_private_key,
_parse_trusted_keys,
_record_catalog_acceptance,
)
from govoplan_core.core.external_references import (
IntegrationMaturity,
SOURCE_AUTHORITY_MODES,
SourceAuthorityMode,
integration_maturity_rank,
)
from govoplan_core.security.http_fetch import fetch_http_text
@@ -28,6 +35,166 @@ CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider"
DiagnosticSeverity = Literal["blocker", "warning", "info"]
PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"]
ConfigurationPackageClass = Literal[
"reference",
"product",
"sector",
"deployment",
"integration",
]
ConfigurationPackageEvidenceKind = Literal[
"target_test",
"migration",
"upgrade",
"recovery",
"security",
"operations",
"accessibility",
"privacy",
"documentation",
]
CONFIGURATION_PACKAGE_CLASSES: tuple[ConfigurationPackageClass, ...] = (
"reference",
"product",
"sector",
"deployment",
"integration",
)
CONFIGURATION_PACKAGE_EVIDENCE_KINDS: tuple[
ConfigurationPackageEvidenceKind, ...
] = (
"target_test",
"migration",
"upgrade",
"recovery",
"security",
"operations",
"accessibility",
"privacy",
"documentation",
)
_SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
@dataclass(frozen=True, slots=True)
class ConfigurationPackageParent:
package_id: str
version: str
relation: Literal["derived_from", "specializes", "extends"] = "derived_from"
def __post_init__(self) -> None:
if not self.package_id.strip() or not self.version.strip():
raise ValueError("Configuration package parent id and version are required.")
if self.relation not in {"derived_from", "specializes", "extends"}:
raise ValueError(
f"Unsupported configuration package parent relation: {self.relation!r}."
)
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationPackageParent":
relation = _optional_str(value, "relation") or "derived_from"
if relation not in {"derived_from", "specializes", "extends"}:
raise ValueError(f"Unsupported configuration package parent relation: {relation!r}.")
return cls(
package_id=_required_str(value, "package_id"),
version=_required_str(value, "version"),
relation=relation,
)
def to_dict(self) -> dict[str, str]:
return {
"package_id": self.package_id,
"version": self.version,
"relation": self.relation,
}
@dataclass(frozen=True, slots=True)
class ConfigurationPackageEvidence:
kind: ConfigurationPackageEvidenceKind
reference: str
summary: str
checksum: str | None = None
def __post_init__(self) -> None:
if self.kind not in CONFIGURATION_PACKAGE_EVIDENCE_KINDS:
raise ValueError(
f"Unsupported configuration package evidence kind: {self.kind!r}."
)
if not self.reference.strip() or not self.summary.strip():
raise ValueError(
"Configuration package evidence reference and summary are required."
)
if self.checksum is not None and not _SHA256_RE.fullmatch(self.checksum):
raise ValueError(
"Configuration package evidence checksum must use sha256:<64 lowercase hex>."
)
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationPackageEvidence":
kind = _required_str(value, "kind")
if kind not in CONFIGURATION_PACKAGE_EVIDENCE_KINDS:
raise ValueError(f"Unsupported configuration package evidence kind: {kind!r}.")
return cls(
kind=kind,
reference=_required_str(value, "reference"),
summary=_required_str(value, "summary"),
checksum=_optional_str(value, "checksum"),
)
def to_dict(self) -> dict[str, object]:
return {
"kind": self.kind,
"reference": self.reference,
"summary": self.summary,
"checksum": self.checksum,
}
@dataclass(frozen=True, slots=True)
class ConfigurationProviderExpectation:
provider_id: str
authority_mode: SourceAuthorityMode
minimum_maturity: IntegrationMaturity
binding_ref: str | None = None
health_expectation: str = "healthy"
freshness_expectation: str | None = None
recovery_expectation: str | None = None
def __post_init__(self) -> None:
if not self.provider_id.strip():
raise ValueError("Configuration provider expectation id is required.")
if self.authority_mode not in SOURCE_AUTHORITY_MODES:
raise ValueError(
f"Unsupported provider authority mode: {self.authority_mode!r}."
)
integration_maturity_rank(self.minimum_maturity)
if not self.health_expectation.strip():
raise ValueError("Provider health expectation is required.")
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationProviderExpectation":
return cls(
provider_id=_required_str(value, "provider_id"),
authority_mode=_required_str(value, "authority_mode"),
minimum_maturity=_required_str(value, "minimum_maturity"),
binding_ref=_optional_str(value, "binding_ref"),
health_expectation=_optional_str(value, "health_expectation") or "healthy",
freshness_expectation=_optional_str(value, "freshness_expectation"),
recovery_expectation=_optional_str(value, "recovery_expectation"),
)
def to_dict(self) -> dict[str, object]:
return {
"provider_id": self.provider_id,
"authority_mode": self.authority_mode,
"minimum_maturity": self.minimum_maturity,
"binding_ref": self.binding_ref,
"health_expectation": self.health_expectation,
"freshness_expectation": self.freshness_expectation,
"recovery_expectation": self.recovery_expectation,
}
@dataclass(frozen=True, slots=True)
@@ -75,6 +242,7 @@ class ConfigurationPackageManifest:
package_id: str
name: str
version: str
package_class: ConfigurationPackageClass = "product"
description: str | None = None
publisher: str | None = None
category: str | None = None
@@ -88,6 +256,29 @@ class ConfigurationPackageManifest:
artifact_ref: str | None = None
artifact_sha256: str | None = None
signature: Mapping[str, Any] | None = None
parents: tuple[ConfigurationPackageParent, ...] = ()
evidence: tuple[ConfigurationPackageEvidence, ...] = ()
provider_expectations: tuple[ConfigurationProviderExpectation, ...] = ()
def __post_init__(self) -> None:
if self.package_class not in CONFIGURATION_PACKAGE_CLASSES:
raise ValueError(
f"Unsupported configuration package class: {self.package_class!r}."
)
parent_keys = {(item.package_id, item.version) for item in self.parents}
if len(parent_keys) != len(self.parents):
raise ValueError("Configuration package parents must be unique.")
evidence_keys = {(item.kind, item.reference) for item in self.evidence}
if len(evidence_keys) != len(self.evidence):
raise ValueError("Configuration package evidence must be unique.")
provider_ids = [item.provider_id for item in self.provider_expectations]
if len(provider_ids) != len(set(provider_ids)):
raise ValueError("Configuration package provider expectations must be unique.")
for expectation in self.provider_expectations:
integration_maturity_rank(expectation.minimum_maturity)
issues = configuration_package_claim_issues(self)
if issues:
raise ValueError("Invalid configuration package claim: " + "; ".join(issues))
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationPackageManifest":
@@ -95,6 +286,7 @@ class ConfigurationPackageManifest:
package_id=_required_str(value, "package_id"),
name=_required_str(value, "name"),
version=_required_str(value, "version"),
package_class=_optional_str(value, "package_class") or "product",
description=_optional_str(value, "description"),
publisher=_optional_str(value, "publisher"),
category=_optional_str(value, "category"),
@@ -108,6 +300,21 @@ class ConfigurationPackageManifest:
artifact_ref=_optional_str(value, "artifact_ref"),
artifact_sha256=_optional_str(value, "artifact_sha256"),
signature=value.get("signature") if isinstance(value.get("signature"), Mapping) else None,
parents=tuple(
ConfigurationPackageParent.from_mapping(item)
for item in _object_list(value.get("parents"), field_name="parents")
),
evidence=tuple(
ConfigurationPackageEvidence.from_mapping(item)
for item in _object_list(value.get("evidence"), field_name="evidence")
),
provider_expectations=tuple(
ConfigurationProviderExpectation.from_mapping(item)
for item in _object_list(
value.get("provider_expectations"),
field_name="provider_expectations",
)
),
)
def to_dict(self) -> dict[str, object]:
@@ -115,12 +322,18 @@ class ConfigurationPackageManifest:
"package_id": self.package_id,
"name": self.name,
"version": self.version,
"package_class": self.package_class,
"required_modules": [item.to_dict() for item in self.required_modules],
"required_capabilities": list(self.required_capabilities),
"optional_modules": [item.to_dict() for item in self.optional_modules],
"fragments": [item.to_dict() for item in self.fragments],
"data_requirements": [dict(item) for item in self.data_requirements],
"tags": list(self.tags),
"parents": [item.to_dict() for item in self.parents],
"evidence": [item.to_dict() for item in self.evidence],
"provider_expectations": [
item.to_dict() for item in self.provider_expectations
],
}
for key, value in (
("description", self.description),
@@ -221,6 +434,12 @@ class ConfigurationPreflightContext:
supplied_data: Mapping[str, Any] = field(default_factory=dict)
installed_modules: Mapping[str, str] = field(default_factory=dict)
capabilities: frozenset[str] = frozenset()
external_provider_declarations: Mapping[str, Mapping[str, Any]] = field(
default_factory=dict
)
external_provider_states: Mapping[str, Mapping[str, Any]] = field(
default_factory=dict
)
dry_run: bool = True
@@ -286,6 +505,7 @@ def dry_run_configuration_package(
diagnostics.extend(_module_requirement_diagnostics(manifest, context))
diagnostics.extend(_capability_requirement_diagnostics(manifest, context))
diagnostics.extend(_provider_expectation_diagnostics(manifest, context))
for item in manifest.data_requirements:
requirement = ConfigurationRequiredData.from_mapping(item)
required_data.append(requirement)
@@ -369,6 +589,8 @@ def apply_configuration_package(
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,
dry_run=False,
)
preflight = dry_run_configuration_package(manifest, providers, apply_context)
@@ -636,33 +858,158 @@ def sign_configuration_package_catalog(*, path: Path, key_id: str, private_key_p
def record_configuration_package_catalog_acceptance(validation: dict[str, object]) -> None:
state_path = _configured_sequence_state_path()
if state_path is None or validation.get("valid") is not True:
return
channel = validation.get("channel")
sequence = validation.get("sequence")
if not isinstance(channel, str) or not isinstance(sequence, int):
return
try:
state = json.loads(state_path.read_text(encoding="utf-8")) if state_path.exists() else {}
except json.JSONDecodeError:
state = {}
if not isinstance(state, dict):
state = {}
channels = state.get("channels")
if not isinstance(channels, dict):
channels = {}
channel_state = channels.get(channel)
if not isinstance(channel_state, dict):
channel_state = {}
channel_state["last_sequence"] = max(int(channel_state.get("last_sequence") or 0), sequence)
channel_state["accepted_at"] = datetime.now(tz=UTC).isoformat().replace("+00:00", "Z")
channel_state["key_id"] = validation.get("key_id")
channel_state["source"] = validation.get("source") or validation.get("path")
channels[channel] = channel_state
state["channels"] = channels
state_path.parent.mkdir(parents=True, exist_ok=True)
state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
_record_catalog_acceptance(
validation,
state_path=_configured_sequence_state_path(),
)
def configuration_package_claim_issues(
manifest: ConfigurationPackageManifest,
) -> tuple[str, ...]:
evidence_kinds = {item.kind for item in manifest.evidence}
required_evidence: dict[str, frozenset[str]] = {
"reference": frozenset(
{
"target_test",
"recovery",
"security",
"operations",
"accessibility",
"privacy",
"documentation",
}
),
"product": frozenset(),
"sector": frozenset({"documentation"}),
"deployment": frozenset(
{"target_test", "recovery", "security", "operations"}
),
"integration": frozenset(
{"target_test", "recovery", "operations", "documentation"}
),
}
issues: list[str] = []
missing = sorted(required_evidence[manifest.package_class] - evidence_kinds)
if missing:
issues.append(
f"{manifest.package_class} package is missing evidence: "
+ ", ".join(missing)
)
if manifest.package_class in {"reference", "deployment", "integration"}:
unbound = sorted(
item.kind
for item in manifest.evidence
if item.kind != "documentation" and item.checksum is None
)
if unbound:
issues.append(
f"{manifest.package_class} package has evidence without checksums: "
+ ", ".join(unbound)
)
if manifest.package_class == "sector" and not manifest.parents:
issues.append("sector packages must declare a parent package/version")
if manifest.package_class == "integration" and not manifest.provider_expectations:
issues.append("integration packages must declare external provider expectations")
return tuple(issues)
def validate_configuration_package_derivation(
child: ConfigurationPackageManifest,
parent: ConfigurationPackageManifest,
) -> tuple[ConfigurationDiagnostic, ...]:
diagnostics: list[ConfigurationDiagnostic] = []
if not any(
item.package_id == parent.package_id and item.version == parent.version
for item in child.parents
):
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="package_parent_provenance_missing",
message=(
f"Package {child.package_id!r} does not declare parent "
f"{parent.package_id}@{parent.version}."
),
object_ref=parent.package_id,
)
)
child_modules = {item.module_id: item for item in child.required_modules}
for requirement in parent.required_modules:
candidate = child_modules.get(requirement.module_id)
if candidate is None or (
requirement.version is not None
and candidate.version != requirement.version
):
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="package_parent_module_constraint_loosened",
message=(
f"Derived package loosens parent module requirement "
f"{requirement.module_id!r}."
),
module_id=requirement.module_id,
)
)
for capability in set(parent.required_capabilities) - set(
child.required_capabilities
):
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="package_parent_capability_constraint_loosened",
message=(
f"Derived package removes required capability {capability!r}."
),
object_ref=capability,
)
)
child_providers = {
item.provider_id: item for item in child.provider_expectations
}
for expectation in parent.provider_expectations:
candidate = child_providers.get(expectation.provider_id)
if candidate is None:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="package_parent_provider_constraint_removed",
message=(
f"Derived package removes provider expectation "
f"{expectation.provider_id!r}."
),
object_ref=expectation.provider_id,
)
)
continue
if candidate.authority_mode != expectation.authority_mode:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="package_parent_authority_mode_changed",
message=(
f"Derived package changes authority mode for provider "
f"{expectation.provider_id!r}."
),
object_ref=expectation.provider_id,
)
)
if integration_maturity_rank(
candidate.minimum_maturity
) < integration_maturity_rank(expectation.minimum_maturity):
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="package_parent_provider_maturity_loosened",
message=(
f"Derived package lowers provider maturity for "
f"{expectation.provider_id!r}."
),
object_ref=expectation.provider_id,
)
)
return tuple(diagnostics)
def _configuration_package_manifest(package: ConfigurationPackageManifest | Mapping[str, Any]) -> ConfigurationPackageManifest:
@@ -716,6 +1063,194 @@ def _capability_requirement_diagnostics(manifest: ConfigurationPackageManifest,
return diagnostics
def _provider_expectation_diagnostics(
manifest: ConfigurationPackageManifest,
context: ConfigurationPreflightContext,
) -> list[ConfigurationDiagnostic]:
diagnostics: list[ConfigurationDiagnostic] = []
for expectation in manifest.provider_expectations:
declaration = context.external_provider_declarations.get(
expectation.provider_id
)
if declaration is None:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="external_provider_missing",
message=(
f"Required external provider {expectation.provider_id!r} "
"is not installed or declared."
),
object_ref=expectation.provider_id,
resolution=(
"Install and enable a module exposing the declared provider."
),
)
)
continue
supported_modes = {
str(item)
for item in declaration.get("authority_modes", ())
if str(item).strip()
}
if expectation.authority_mode not in supported_modes:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="external_provider_authority_mode_unsupported",
message=(
f"Provider {expectation.provider_id!r} does not support "
f"authority mode {expectation.authority_mode!r}."
),
object_ref=expectation.provider_id,
)
)
actual_maturity = str(declaration.get("maturity") or "discover")
try:
maturity_sufficient = integration_maturity_rank(
cast(IntegrationMaturity, actual_maturity)
) >= integration_maturity_rank(expectation.minimum_maturity)
except ValueError:
maturity_sufficient = False
if not maturity_sufficient:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="external_provider_maturity_insufficient",
message=(
f"Provider {expectation.provider_id!r} has maturity "
f"{actual_maturity!r}; {expectation.minimum_maturity!r} is required."
),
object_ref=expectation.provider_id,
)
)
provider_state = context.external_provider_states.get(
expectation.provider_id
)
if provider_state is None:
diagnostics.append(
ConfigurationDiagnostic(
severity="warning",
code="external_provider_health_unverified",
message=(
f"Provider {expectation.provider_id!r} has no current "
"health/freshness observation."
),
object_ref=expectation.provider_id,
)
)
continue
state = provider_state
if expectation.binding_ref is not None:
bindings = provider_state.get("bindings")
matching_binding = next(
(
item
for item in bindings
if isinstance(item, Mapping)
and str(item.get("binding_ref") or "")
== expectation.binding_ref
),
None,
) if isinstance(bindings, Sequence) and not isinstance(
bindings, (str, bytes)
) else None
if matching_binding is None and str(
provider_state.get("binding_ref") or ""
) == expectation.binding_ref:
matching_binding = provider_state
if matching_binding is None:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="external_provider_binding_mismatch",
message=(
f"Provider {expectation.provider_id!r} is not observed through "
f"required binding {expectation.binding_ref!r}."
),
object_ref=expectation.provider_id,
)
)
continue
state = matching_binding
health = str(state.get("health") or state.get("health_state") or "unknown")
accepted_health = (
{"ok", "healthy"}
if expectation.health_expectation == "healthy"
else {expectation.health_expectation}
)
if health not in accepted_health:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="external_provider_unhealthy",
message=(
f"Provider {expectation.provider_id!r} health is {health!r}."
),
object_ref=expectation.provider_id,
resolution="Restore provider health or use a documented degraded path.",
)
)
observed_authority_mode = str(state.get("authority_mode") or "")
if (
observed_authority_mode
and observed_authority_mode != expectation.authority_mode
):
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="external_provider_binding_authority_mismatch",
message=(
f"Provider {expectation.provider_id!r} is configured as "
f"{observed_authority_mode!r}; {expectation.authority_mode!r} "
"is required."
),
object_ref=expectation.provider_id,
)
)
if expectation.freshness_expectation is not None:
freshness = str(
state.get("freshness")
or state.get("freshness_state")
or "unknown"
)
if freshness != expectation.freshness_expectation:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="external_provider_freshness_expectation_failed",
message=(
f"Provider {expectation.provider_id!r} freshness is "
f"{freshness!r}; {expectation.freshness_expectation!r} is required."
),
object_ref=expectation.provider_id,
)
)
if expectation.recovery_expectation is not None:
behavior = declaration.get("behavior")
declared_recovery = (
behavior.get(expectation.recovery_expectation)
if isinstance(behavior, Mapping)
else None
)
observed_recovery = state.get("recovery") or state.get(
"recovery_state"
)
if not declared_recovery and observed_recovery != expectation.recovery_expectation:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="external_provider_recovery_expectation_failed",
message=(
f"Provider {expectation.provider_id!r} does not satisfy "
f"recovery expectation {expectation.recovery_expectation!r}."
),
object_ref=expectation.provider_id,
)
)
return diagnostics
def _dedupe_diagnostics(items: Sequence[ConfigurationDiagnostic]) -> list[ConfigurationDiagnostic]:
seen: set[tuple[object, ...]] = set()
result: list[ConfigurationDiagnostic] = []
+226
View File
@@ -0,0 +1,226 @@
from __future__ import annotations
from collections import Counter
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
from urllib.parse import urlsplit
CONNECTOR_RUNTIME_CONTRACT_VERSION = "1.0"
ConnectorDiagnosticSeverity = Literal["info", "warning", "error"]
ConnectorDiagnosticStage = Literal[
"configuration",
"authentication",
"discovery",
"read",
"mapping",
"planning",
"apply",
"reconciliation",
]
ConnectorEffectKind = Literal[
"create",
"update",
"delete",
"conflict",
"unchanged",
"ignored",
]
ConnectorOutcomeState = Literal[
"preview",
"accepted",
"rejected",
"outcome_unknown",
]
class ConnectorContractError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class ConnectorEndpoint:
"""Sanitized endpoint identity. Credentials never belong in this value."""
url: str
credential_ref: str | None = None
tls_mode: Literal["required", "start_tls", "system", "disabled"] = "required"
options: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
normalized = self.url.strip()
parsed = urlsplit(normalized)
if not parsed.scheme or not parsed.hostname:
raise ConnectorContractError("Connector endpoints require an absolute URL.")
if parsed.username is not None or parsed.password is not None:
raise ConnectorContractError(
"Connector endpoint URLs must not contain credentials."
)
object.__setattr__(self, "url", normalized)
if self.credential_ref is not None:
credential_ref = self.credential_ref.strip()
if not credential_ref:
raise ConnectorContractError("Credential references cannot be blank.")
object.__setattr__(self, "credential_ref", credential_ref)
@dataclass(frozen=True, slots=True)
class ConnectorDiagnostic:
severity: ConnectorDiagnosticSeverity
code: str
message: str
stage: ConnectorDiagnosticStage
retryable: bool = False
source_ref: str | None = None
object_ref: str | None = None
details: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
if not self.code.strip() or len(self.code) > 120:
raise ConnectorContractError(
"Connector diagnostic codes must contain 1 to 120 characters."
)
if not self.message.strip():
raise ConnectorContractError("Connector diagnostic messages cannot be blank.")
@dataclass(frozen=True, slots=True)
class ConnectorEffectPreview:
effect: ConnectorEffectKind
source_object_ref: str
target_object_ref: str | None = None
changed_fields: tuple[str, ...] = ()
sample: Mapping[str, object] = field(default_factory=dict)
reason_code: str | None = None
outcome: ConnectorOutcomeState = "preview"
revision: str | None = None
def __post_init__(self) -> None:
if not self.source_object_ref.strip():
raise ConnectorContractError("Preview effects require a source object reference.")
if self.outcome != "preview":
raise ConnectorContractError("Dry-run effects must retain the preview outcome.")
@dataclass(frozen=True, slots=True)
class ConnectorEffectSummary:
creates: int = 0
updates: int = 0
deletes: int = 0
conflicts: int = 0
unchanged: int = 0
ignored: int = 0
@property
def total(self) -> int:
return (
self.creates
+ self.updates
+ self.deletes
+ self.conflicts
+ self.unchanged
+ self.ignored
)
@dataclass(frozen=True, slots=True)
class ConnectorDryRunRequest:
tenant_id: str
source_ref: str
force_full: bool = False
max_items: int = 1_000
expected_source_revision: str | None = None
context: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
if not self.tenant_id.strip() or not self.source_ref.strip():
raise ConnectorContractError("Dry runs require tenant and source references.")
if not 1 <= self.max_items <= 10_000:
raise ConnectorContractError("Dry-run max_items must be between 1 and 10000.")
@dataclass(frozen=True, slots=True)
class ConnectorDryRunResult:
contract_version: str
source_ref: str
source_revision: str
source_fingerprint: str
input_hash: str
generated_at: datetime
summary: ConnectorEffectSummary
effects: tuple[ConnectorEffectPreview, ...] = ()
diagnostics: tuple[ConnectorDiagnostic, ...] = ()
truncated: bool = False
stale: bool = False
apply_token: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
if self.contract_version != CONNECTOR_RUNTIME_CONTRACT_VERSION:
raise ConnectorContractError(
f"Unsupported connector contract version: {self.contract_version!r}."
)
for name in ("source_ref", "source_revision", "source_fingerprint", "input_hash"):
if not str(getattr(self, name)).strip():
raise ConnectorContractError(f"Dry-run {name} cannot be blank.")
if self.summary.total != len(self.effects):
raise ConnectorContractError(
"Dry-run summary counts must match the returned effect list."
)
@property
def can_apply(self) -> bool:
return (
self.apply_token is not None
and not self.truncated
and not self.stale
and self.summary.conflicts == 0
and not any(item.severity == "error" for item in self.diagnostics)
)
def summarize_connector_effects(
effects: tuple[ConnectorEffectPreview, ...],
) -> ConnectorEffectSummary:
counts = Counter(item.effect for item in effects)
return ConnectorEffectSummary(
creates=counts["create"],
updates=counts["update"],
deletes=counts["delete"],
conflicts=counts["conflict"],
unchanged=counts["unchanged"],
ignored=counts["ignored"],
)
@runtime_checkable
class ConnectorDryRunProvider(Protocol):
def preview(
self,
session: object,
principal: object,
*,
request: ConnectorDryRunRequest,
) -> ConnectorDryRunResult:
...
__all__ = [
"CONNECTOR_RUNTIME_CONTRACT_VERSION",
"ConnectorContractError",
"ConnectorDiagnostic",
"ConnectorDiagnosticSeverity",
"ConnectorDiagnosticStage",
"ConnectorDryRunProvider",
"ConnectorDryRunRequest",
"ConnectorDryRunResult",
"ConnectorEffectKind",
"ConnectorEffectPreview",
"ConnectorEffectSummary",
"ConnectorEndpoint",
"ConnectorOutcomeState",
"summarize_connector_effects",
]
+191
View File
@@ -0,0 +1,191 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.distribution_lists import (
DistributionChannel,
DistributionExplanation,
DistributionOutcome,
DistributionSourceReference,
)
CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION = "addresses.contact_point_resolution"
CONTACT_POINT_CONTRACT_VERSION = "1.0"
ContactPointFallbackRule = Literal["none", "primary", "any"]
PostalAddressFormat = Literal["domestic", "international"]
@dataclass(frozen=True, slots=True)
class ContactPointResolutionRequest:
tenant_id: str
subject: DistributionSourceReference
effective_at: datetime
purpose: str | None = None
requested_channels: tuple[DistributionChannel, ...] = ()
address_purpose: str | None = None
fallback_rule: ContactPointFallbackRule = "primary"
locale: str | None = None
postal_format: PostalAddressFormat = "domestic"
context: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ContactPointCandidate:
channel: DistributionChannel
target: str
target_key: str
status: DistributionOutcome
contact_point_id: str | None = None
address_purpose: str | None = None
locale: str | None = None
preferred: bool = False
preference_rank: int | None = None
reason_code: str | None = None
explanation: str | None = None
source: DistributionSourceReference | None = None
source_revision: str | None = None
preference_revision: str | None = None
consent_revision: str | None = None
value: Mapping[str, object] = field(default_factory=dict)
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ContactPointResolution:
contract_version: str
subject: DistributionSourceReference
status: DistributionOutcome
contact_id: str | None = None
display_name: str | None = None
candidates: tuple[ContactPointCandidate, ...] = ()
excluded: tuple[ContactPointCandidate, ...] = ()
explanations: tuple[DistributionExplanation, ...] = ()
source_revision: str | None = None
source_fingerprint: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ContactPointSourceRequest:
tenant_id: str
source_id: str
effective_at: datetime
purpose: str | None = None
requested_channels: tuple[DistributionChannel, ...] = ()
address_purpose: str | None = None
fallback_rule: ContactPointFallbackRule = "primary"
locale: str | None = None
postal_format: PostalAddressFormat = "domestic"
max_items: int = 5_000
context: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ContactPointSourcePreview:
contract_version: str
source: DistributionSourceReference
request: ContactPointSourceRequest
resolutions: tuple[ContactPointResolution, ...]
total_count: int
usable_count: int
excluded_count: int
offset: int
limit: int
has_more: bool
source_revision: str
source_fingerprint: str
generated_at: datetime
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ContactPointSnapshotRef:
id: str
tenant_id: str
contract_version: str
source: DistributionSourceReference
request: ContactPointSourceRequest
resolutions: tuple[ContactPointResolution, ...]
recipient_count: int
excluded_count: int
source_revision: str
source_fingerprint: str
snapshot_hash: str
generated_at: datetime
provenance: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class ContactPointResolutionProvider(Protocol):
def resolve_contact_points(
self,
session: object,
principal: object,
*,
request: ContactPointResolutionRequest,
) -> ContactPointResolution:
...
def preview_source(
self,
session: object,
principal: object,
*,
request: ContactPointSourceRequest,
offset: int = 0,
limit: int = 100,
) -> ContactPointSourcePreview:
...
def freeze_source(
self,
session: object,
principal: object,
*,
request: ContactPointSourceRequest,
) -> ContactPointSnapshotRef:
...
def get_snapshot(
self,
session: object,
principal: object,
*,
snapshot_id: str,
) -> ContactPointSnapshotRef | None:
...
def contact_point_resolution_provider(
registry: object | None,
) -> ContactPointResolutionProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION)
):
return None
capability = registry.capability(CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION)
return capability if isinstance(capability, ContactPointResolutionProvider) else None
__all__ = [
"CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION",
"CONTACT_POINT_CONTRACT_VERSION",
"ContactPointCandidate",
"ContactPointFallbackRule",
"ContactPointResolution",
"ContactPointResolutionProvider",
"ContactPointResolutionRequest",
"ContactPointSnapshotRef",
"ContactPointSourcePreview",
"ContactPointSourceRequest",
"PostalAddressFormat",
"contact_point_resolution_provider",
]
+79 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
@@ -12,6 +12,7 @@ from govoplan_core.core.events import PlatformEvent
CAPABILITY_DATAFLOW_RUN_LIFECYCLE = "dataflow.runLifecycle"
CAPABILITY_DATAFLOW_RUN_WORKER = "dataflow.runWorker"
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER = "dataflow.triggerDispatcher"
CAPABILITY_DATAFLOW_DATASET_OUTPUT = "dataflow.dataset_output"
class DataflowRunError(ValueError):
@@ -30,6 +31,67 @@ class DataflowRunUnavailableError(DataflowRunError):
pass
@dataclass(frozen=True, slots=True)
class DataflowDatasetDescriptor:
pipeline_ref: str
name: str
revision: int
definition_hash: str
status: str
description: str | None = None
updated_at: datetime | None = None
parameters: Mapping[str, object] = field(default_factory=dict)
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DataflowDatasetRequest:
pipeline_ref: str
revision: int
parameters: Mapping[str, object] = field(default_factory=dict)
row_limit: int = 500
expected_definition_hash: str | None = None
expected_source_fingerprints: tuple[Mapping[str, object], ...] = ()
run_ref: str | None = None
@dataclass(frozen=True, slots=True)
class DataflowDatasetResult:
pipeline_ref: str
revision: int
definition_hash: str
rows: tuple[Mapping[str, object], ...]
total_rows: int
truncated: bool
output_hash: str
executor_version: str
run_ref: str | None = None
source_fingerprints: tuple[Mapping[str, object], ...] = ()
diagnostics: tuple[Mapping[str, object], ...] = ()
generated_at: datetime | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class DataflowDatasetOutputProvider(Protocol):
def list_outputs(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
) -> Sequence[DataflowDatasetDescriptor]: ...
def read_output(
self,
session: object,
principal: object,
*,
request: DataflowDatasetRequest,
) -> DataflowDatasetResult: ...
@dataclass(frozen=True, slots=True)
class DataflowPublicationTarget:
target_datasource_ref: str | None = None
@@ -117,6 +179,7 @@ class DataflowTriggerDispatcher(Protocol):
self,
session: object,
*,
tenant_id: str | None = None,
now: datetime | None = None,
limit: int = 50,
) -> Mapping[str, object]:
@@ -143,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,
@@ -153,6 +217,7 @@ class DataflowRunWorker(Protocol):
self,
session: object,
*,
tenant_id: str | None = None,
now: datetime | None = None,
limit: int = 500,
) -> Mapping[str, object]:
@@ -184,6 +249,13 @@ def dataflow_trigger_dispatcher(
)
def dataflow_dataset_output(
registry: object | None,
) -> DataflowDatasetOutputProvider | None:
capability = _capability(registry, CAPABILITY_DATAFLOW_DATASET_OUTPUT)
return capability if isinstance(capability, DataflowDatasetOutputProvider) else None
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
@@ -199,6 +271,11 @@ __all__ = [
"CAPABILITY_DATAFLOW_RUN_LIFECYCLE",
"CAPABILITY_DATAFLOW_RUN_WORKER",
"CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER",
"CAPABILITY_DATAFLOW_DATASET_OUTPUT",
"DataflowDatasetDescriptor",
"DataflowDatasetOutputProvider",
"DataflowDatasetRequest",
"DataflowDatasetResult",
"DataflowPublicationTarget",
"DataflowRunConflictError",
"DataflowRunDescriptor",
@@ -212,4 +289,5 @@ __all__ = [
"dataflow_run_lifecycle",
"dataflow_run_worker",
"dataflow_trigger_dispatcher",
"dataflow_dataset_output",
]
+206
View File
@@ -5,6 +5,19 @@ from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.external_references import (
SOURCE_AUTHORITY_MODES,
SourceAuthorityMode,
)
from govoplan_core.core.tabular_sources import (
DEFAULT_PREVIEW_BYTES,
DEFAULT_PREVIEW_TIMEOUT_MS,
TabularPreviewDiagnostic,
TabularPushdown,
TabularSourceHealth,
TabularSourceMode,
)
CAPABILITY_DATASOURCE_CATALOGUE = "datasources.catalogue"
CAPABILITY_DATASOURCE_LIFECYCLE = "datasources.lifecycle"
@@ -53,6 +66,137 @@ class DatasourceField:
nullable: bool = True
@dataclass(frozen=True, slots=True)
class DatasourceGovernance:
"""Provider-neutral governance facts attached to a datasource revision."""
owner_ref: str | None = None
steward_ref: str | None = None
responsible_organization_ref: str | None = None
responsible_function_ref: str | None = None
authoritative_source_ref: str | None = None
authority_mode: SourceAuthorityMode = "linked_reference"
legal_basis_refs: tuple[str, ...] = ()
purposes: tuple[str, ...] = ()
semantic_definition: str | None = None
schema_owner_ref: str | None = None
official_keys: tuple[str, ...] = ()
classification: str = "internal"
privacy_profile_ref: str | None = None
retention_policy_ref: str | None = None
hold_refs: tuple[str, ...] = ()
publication_state: str = "draft"
transfer_agreement_ref: str | None = None
freshness_policy: Mapping[str, object] = field(default_factory=dict)
quality_policy: Mapping[str, object] = field(default_factory=dict)
known_limits: tuple[str, ...] = ()
correction_procedure_ref: str | None = None
affected_refs: tuple[str, ...] = ()
dependency_refs: tuple[str, ...] = ()
def __post_init__(self) -> None:
if self.authority_mode not in SOURCE_AUTHORITY_MODES:
raise DatasourceValidationError(
f"Unsupported datasource authority mode: {self.authority_mode!r}."
)
if not self.classification.strip():
raise DatasourceValidationError("Datasource classification is required.")
if not self.publication_state.strip():
raise DatasourceValidationError("Datasource publication state is required.")
for field_name in (
"legal_basis_refs",
"purposes",
"official_keys",
"hold_refs",
"known_limits",
"affected_refs",
"dependency_refs",
):
values = getattr(self, field_name)
if any(not value.strip() for value in values):
raise DatasourceValidationError(
f"Datasource governance {field_name} cannot contain empty values."
)
if len(values) != len(set(values)):
raise DatasourceValidationError(
f"Datasource governance {field_name} cannot contain duplicates."
)
@classmethod
def from_mapping(cls, value: Mapping[str, object] | None) -> "DatasourceGovernance":
source = value or {}
return cls(
owner_ref=_optional_governance_text(source.get("owner_ref")),
steward_ref=_optional_governance_text(source.get("steward_ref")),
responsible_organization_ref=_optional_governance_text(
source.get("responsible_organization_ref")
),
responsible_function_ref=_optional_governance_text(
source.get("responsible_function_ref")
),
authoritative_source_ref=_optional_governance_text(
source.get("authoritative_source_ref")
),
authority_mode=str(
source.get("authority_mode") or "linked_reference"
), # type: ignore[arg-type]
legal_basis_refs=_governance_texts(source.get("legal_basis_refs")),
purposes=_governance_texts(source.get("purposes")),
semantic_definition=_optional_governance_text(
source.get("semantic_definition")
),
schema_owner_ref=_optional_governance_text(source.get("schema_owner_ref")),
official_keys=_governance_texts(source.get("official_keys")),
classification=str(source.get("classification") or "internal"),
privacy_profile_ref=_optional_governance_text(
source.get("privacy_profile_ref")
),
retention_policy_ref=_optional_governance_text(
source.get("retention_policy_ref")
),
hold_refs=_governance_texts(source.get("hold_refs")),
publication_state=str(source.get("publication_state") or "draft"),
transfer_agreement_ref=_optional_governance_text(
source.get("transfer_agreement_ref")
),
freshness_policy=_governance_mapping(source.get("freshness_policy")),
quality_policy=_governance_mapping(source.get("quality_policy")),
known_limits=_governance_texts(source.get("known_limits")),
correction_procedure_ref=_optional_governance_text(
source.get("correction_procedure_ref")
),
affected_refs=_governance_texts(source.get("affected_refs")),
dependency_refs=_governance_texts(source.get("dependency_refs")),
)
def to_dict(self) -> dict[str, object]:
return {
"owner_ref": self.owner_ref,
"steward_ref": self.steward_ref,
"responsible_organization_ref": self.responsible_organization_ref,
"responsible_function_ref": self.responsible_function_ref,
"authoritative_source_ref": self.authoritative_source_ref,
"authority_mode": self.authority_mode,
"legal_basis_refs": list(self.legal_basis_refs),
"purposes": list(self.purposes),
"semantic_definition": self.semantic_definition,
"schema_owner_ref": self.schema_owner_ref,
"official_keys": list(self.official_keys),
"classification": self.classification,
"privacy_profile_ref": self.privacy_profile_ref,
"retention_policy_ref": self.retention_policy_ref,
"hold_refs": list(self.hold_refs),
"publication_state": self.publication_state,
"transfer_agreement_ref": self.transfer_agreement_ref,
"freshness_policy": dict(self.freshness_policy),
"quality_policy": dict(self.quality_policy),
"known_limits": list(self.known_limits),
"correction_procedure_ref": self.correction_procedure_ref,
"affected_refs": list(self.affected_refs),
"dependency_refs": list(self.dependency_refs),
}
@dataclass(frozen=True, slots=True)
class DatasourceDescriptor:
ref: str
@@ -75,6 +219,7 @@ class DatasourceDescriptor:
capabilities: tuple[str, ...] = ("read",)
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
governance: DatasourceGovernance = field(default_factory=DatasourceGovernance)
@dataclass(frozen=True, slots=True)
@@ -93,6 +238,7 @@ class DatasourceMaterialization:
created_at: datetime | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
governance: DatasourceGovernance = field(default_factory=DatasourceGovernance)
@dataclass(frozen=True, slots=True)
@@ -115,6 +261,7 @@ class DatasourceStage:
promoted_materialization_ref: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
governance: DatasourceGovernance = field(default_factory=DatasourceGovernance)
@dataclass(frozen=True, slots=True)
@@ -126,6 +273,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)
@@ -135,6 +284,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)
@@ -151,6 +306,7 @@ class DatasourceStageInput:
provider_ref: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
governance: DatasourceGovernance | None = None
@dataclass(frozen=True, slots=True)
@@ -169,6 +325,7 @@ class DatasourcePublicationRequest:
source_timestamp: datetime | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
governance: DatasourceGovernance | None = None
@dataclass(frozen=True, slots=True)
@@ -198,6 +355,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)
@@ -207,6 +367,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)
@@ -215,6 +377,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
@@ -226,6 +394,13 @@ class DatasourceCatalogueProvider(Protocol):
*,
query: str = "",
limit: int = 100,
authority_mode: str | None = None,
classification: str | None = None,
publication_state: str | None = None,
owner_ref: str | None = None,
responsible_organization_ref: str | None = None,
affected_ref: str | None = None,
dependency_ref: str | None = None,
) -> Sequence[DatasourceDescriptor]:
...
@@ -298,6 +473,17 @@ class DatasourceLifecycleProvider(Protocol):
source_name: str,
mode: DatasourceMode,
description: str | None = None,
governance: DatasourceGovernance | None = None,
) -> DatasourceDescriptor:
...
def update_datasource_governance(
self,
session: object,
principal: object,
*,
datasource_ref: str,
governance: DatasourceGovernance,
) -> DatasourceDescriptor:
...
@@ -406,6 +592,25 @@ def _capability(registry: object | None, name: str) -> object | None:
return registry.capability(name)
def _optional_governance_text(value: object) -> str | None:
if value is None:
return None
cleaned = str(value).strip()
return cleaned or None
def _governance_texts(value: object) -> tuple[str, ...]:
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
return ()
return tuple(str(item).strip() for item in value)
def _governance_mapping(value: object) -> Mapping[str, object]:
if not isinstance(value, Mapping):
return {}
return {str(key): item for key, item in value.items()}
__all__ = [
"CAPABILITY_DATASOURCE_CATALOGUE",
"CAPABILITY_DATASOURCE_LIFECYCLE",
@@ -416,6 +621,7 @@ __all__ = [
"DatasourceDescriptor",
"DatasourceError",
"DatasourceField",
"DatasourceGovernance",
"DatasourceKind",
"DatasourceLifecycleProvider",
"DatasourceMaterialization",
@@ -0,0 +1,402 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
CAPABILITY_DISTRIBUTION_LIST_SOURCE = "dist_lists.source"
CAPABILITY_DISTRIBUTION_LIST_EXPAND = "dist_lists.expand"
CAPABILITY_DISTRIBUTION_LIST_WRITER = "dist_lists.writer"
CAPABILITY_RECIPIENT_CHANNEL_FACTS = "addresses.channel_facts"
CAPABILITY_POLICY_DISTRIBUTION_CHANNELS = "policy.distribution_channels"
DistributionDefinitionKind = Literal["static", "parameterized", "dynamic", "template"]
DistributionEntryMode = Literal["include", "exclude", "override"]
DistributionEntryKind = Literal[
"address_contact",
"address_list",
"address_email",
"raw_email",
"raw_postal_address",
"internal_mail",
"portal",
"idm_identity",
"idm_group",
"organization_unit",
"function",
"effective_function_incumbent",
"dataflow_result",
"distribution_list",
]
DistributionChannel = Literal["email", "postal", "internal_mail", "portal"]
DistributionOutcome = Literal[
"usable",
"unresolved",
"invalid",
"suppressed",
"ambiguous",
"duplicate",
"policy_blocked",
"provider_unavailable",
"stale",
]
DistributionExplanationSeverity = Literal["info", "warning", "error"]
class DistributionListError(ValueError):
"""Stable base error for provider-neutral distribution-list operations."""
class DistributionListNotFoundError(DistributionListError):
pass
class DistributionListConflictError(DistributionListError):
pass
class DistributionListUnavailableError(DistributionListError):
pass
@dataclass(frozen=True, slots=True)
class DistributionSourceReference:
provider: str
resource_type: str
resource_id: str
revision: str | None = None
fingerprint: str | None = None
label: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionExplanation:
code: str
message: str
severity: DistributionExplanationSeverity = "warning"
provider: str | None = None
source: DistributionSourceReference | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionParameterDefinition:
key: str
value_type: Literal[
"string",
"integer",
"number",
"boolean",
"date",
"datetime",
"string_list",
]
label: str | None = None
required: bool = False
default: object | None = None
allowed_values: tuple[object, ...] = ()
minimum: float | None = None
maximum: float | None = None
pattern: str | None = None
description: str | None = None
@dataclass(frozen=True, slots=True)
class DistributionListEntryRef:
id: str
kind: DistributionEntryKind
mode: DistributionEntryMode
source: DistributionSourceReference
label: str | None = None
purpose: str | None = None
requested_channels: tuple[DistributionChannel, ...] = ()
effective_from: datetime | None = None
effective_until: datetime | None = None
order: int = 0
configuration: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionListSourceRef:
id: str
tenant_id: str
name: str
revision_id: str
revision: int
definition_hash: str
definition_kind: DistributionDefinitionKind = "static"
description: str | None = None
status: str = "active"
entry_count: int = 0
read_only: bool = False
stale: bool = False
parameters: tuple[DistributionParameterDefinition, ...] = ()
updated_at: datetime | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionExpansionLimits:
max_entries: int = 500
max_results: int = 5_000
max_depth: int = 8
max_provider_results: int = 2_000
@dataclass(frozen=True, slots=True)
class DistributionExpansionRequest:
list_id: str
revision: int | None = None
effective_at: datetime | None = None
purpose: str | None = None
requested_channels: tuple[DistributionChannel, ...] = ()
parameters: Mapping[str, object] = field(default_factory=dict)
preview: bool = False
freeze: bool = False
idempotency_key: str | None = None
limits: DistributionExpansionLimits = field(
default_factory=DistributionExpansionLimits
)
@dataclass(frozen=True, slots=True)
class DistributionChannelCandidate:
channel: DistributionChannel
target: str
target_key: str
status: DistributionOutcome = "usable"
contact_point_id: str | None = None
locale: str | None = None
preferred: bool = False
reason_code: str | None = None
explanation: str | None = None
source: DistributionSourceReference | None = None
decision_provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionRecipientRef:
recipient_key: str
display_name: str
status: DistributionOutcome
channels: tuple[DistributionChannelCandidate, ...] = ()
identity_id: str | None = None
account_id: str | None = None
contact_id: str | None = None
organization_unit_id: str | None = None
function_id: str | None = None
source_entry_ids: tuple[str, ...] = ()
explanations: tuple[DistributionExplanation, ...] = ()
attributes: Mapping[str, object] = field(default_factory=dict)
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionProviderEvidence:
provider: str
source: DistributionSourceReference
actual_revision: str | None = None
actual_fingerprint: str | None = None
stale: bool = False
generated_at: datetime | None = None
details: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionExpansionResult:
source: DistributionListSourceRef
request: DistributionExpansionRequest
recipients: tuple[DistributionRecipientRef, ...]
excluded: tuple[DistributionRecipientRef, ...] = ()
diagnostics: tuple[DistributionExplanation, ...] = ()
provider_evidence: tuple[DistributionProviderEvidence, ...] = ()
expansion_hash: str = ""
generated_at: datetime | None = None
snapshot_id: str | None = None
stale: bool = False
truncated: bool = False
@dataclass(frozen=True, slots=True)
class DistributionSnapshotRef:
id: str
tenant_id: str
list_id: str
revision_id: str
revision: int
expansion_hash: str
generated_at: datetime
effective_at: datetime
recipient_count: int
excluded_count: int
stale: bool
truncated: bool
request: Mapping[str, object] = field(default_factory=dict)
recipients: tuple[DistributionRecipientRef, ...] = ()
excluded: tuple[DistributionRecipientRef, ...] = ()
diagnostics: tuple[DistributionExplanation, ...] = ()
provider_evidence: tuple[DistributionProviderEvidence, ...] = ()
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionWriteDecision:
list_id: str | None
operation: str
allowed: bool
reason_code: str
explanation: str
read_only: bool = False
required_scopes: tuple[str, ...] = ()
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class RecipientChannelFactsRequest:
tenant_id: str
source: DistributionSourceReference
recipient_key: str
effective_at: datetime
purpose: str | None = None
requested_channels: tuple[DistributionChannel, ...] = ()
context: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class RecipientChannelFacts:
candidates: tuple[DistributionChannelCandidate, ...]
explanations: tuple[DistributionExplanation, ...] = ()
source_revision: str | None = None
source_fingerprint: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionChannelPolicyRequest:
tenant_id: str
list_id: str
purpose: str | None
effective_at: datetime
recipient: DistributionRecipientRef
candidate: DistributionChannelCandidate
context: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionChannelPolicyDecision:
allowed: bool
reason_code: str
explanation: str
source_path: tuple[Mapping[str, object], ...] = ()
requirements: tuple[str, ...] = ()
details: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class DistributionListSourceProvider(Protocol):
def list_sources(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
) -> Sequence[DistributionListSourceRef]: ...
def get_source(
self,
session: object,
principal: object,
*,
list_id: str,
revision: int | None = None,
) -> DistributionListSourceRef | None: ...
@runtime_checkable
class DistributionListExpansionProvider(Protocol):
def expand(
self,
session: object,
principal: object,
*,
request: DistributionExpansionRequest,
) -> DistributionExpansionResult: ...
def get_snapshot(
self,
session: object,
principal: object,
*,
snapshot_id: str,
) -> DistributionSnapshotRef | None: ...
@runtime_checkable
class DistributionListWriter(Protocol):
def explain_write(
self,
session: object,
principal: object,
*,
list_id: str | None,
operation: str,
) -> DistributionWriteDecision: ...
@runtime_checkable
class RecipientChannelFactsProvider(Protocol):
def resolve_channel_facts(
self,
session: object,
principal: object,
*,
request: RecipientChannelFactsRequest,
) -> RecipientChannelFacts: ...
@runtime_checkable
class DistributionChannelPolicyProvider(Protocol):
def resolve_distribution_channel(
self,
session: object,
principal: object,
*,
request: DistributionChannelPolicyRequest,
) -> DistributionChannelPolicyDecision: ...
def distribution_list_source_provider(
registry: object | None,
) -> DistributionListSourceProvider | None:
capability = _capability(registry, CAPABILITY_DISTRIBUTION_LIST_SOURCE)
return capability if isinstance(capability, DistributionListSourceProvider) else None
def distribution_list_expansion_provider(
registry: object | None,
) -> DistributionListExpansionProvider | None:
capability = _capability(registry, CAPABILITY_DISTRIBUTION_LIST_EXPAND)
return (
capability
if isinstance(capability, DistributionListExpansionProvider)
else None
)
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
__all__ = [name for name in globals() if name.startswith("CAPABILITY_") or name.startswith("Distribution") or name.startswith("Recipient") or name.startswith("distribution_")]
File diff suppressed because it is too large Load Diff
+13
View File
@@ -13,6 +13,8 @@ import uuid
from sqlalchemy import event as sqlalchemy_event
from sqlalchemy.orm import Session
from govoplan_core.core.institutional import GovernedContextEnvelope
_TRACE_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
_CONSUMER_ID_RE = re.compile(r"^[a-z][a-z0-9_.:-]{0,127}$")
@@ -87,6 +89,7 @@ class PlatformEvent:
subject: EventObjectRef | None = None
resource: EventObjectRef | None = None
classification: EventClassification = "internal"
institutional_context: GovernedContextEnvelope | None = None
def to_dict(self) -> dict[str, Any]:
return {
@@ -102,6 +105,11 @@ class PlatformEvent:
"subject": self.subject.to_dict() if self.subject else None,
"resource": self.resource.to_dict() if self.resource else None,
"classification": self.classification,
"institutional_context": (
self.institutional_context.to_dict()
if self.institutional_context is not None
else None
),
}
@@ -174,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,
@@ -195,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]:
@@ -254,6 +266,7 @@ def ensure_event_trace(event: PlatformEvent) -> PlatformEvent:
subject=event.subject,
resource=event.resource,
classification=event.classification,
institutional_context=event.institutional_context,
)
@@ -17,6 +17,14 @@ IntegrationMaturity = Literal[
"migrate",
"replace",
]
SourceAuthorityMode = Literal[
"native_authoritative",
"external_authoritative",
"external_mirror",
"governed_sync",
"governance_overlay",
"linked_reference",
]
INTEGRATION_MATURITY_ORDER: tuple[IntegrationMaturity, ...] = (
"discover",
@@ -28,6 +36,14 @@ INTEGRATION_MATURITY_ORDER: tuple[IntegrationMaturity, ...] = (
"migrate",
"replace",
)
SOURCE_AUTHORITY_MODES: tuple[SourceAuthorityMode, ...] = (
"native_authoritative",
"external_authoritative",
"external_mirror",
"governed_sync",
"governance_overlay",
"linked_reference",
)
class ExternalReferenceValidationError(ValueError):
@@ -42,6 +58,7 @@ class ExternalObjectReference:
object_type: str
object_id: str
maturity: IntegrationMaturity = "link"
authority_mode: SourceAuthorityMode = "linked_reference"
connector_id: str | None = None
canonical_url: str | None = None
version: str | None = None
@@ -65,6 +82,24 @@ class ExternalObjectReference:
raise ExternalReferenceValidationError(
f"Unsupported integration maturity: {self.maturity!r}."
)
if self.authority_mode not in SOURCE_AUTHORITY_MODES:
raise ExternalReferenceValidationError(
f"Unsupported source-authority mode: {self.authority_mode!r}."
)
if (
self.authority_mode == "external_mirror"
and not self.supports("read")
):
raise ExternalReferenceValidationError(
"External-mirror references require read maturity or higher."
)
if (
self.authority_mode == "governed_sync"
and not self.supports("synchronize")
):
raise ExternalReferenceValidationError(
"Governed-sync references require synchronize maturity or higher."
)
if self.connector_id is not None:
connector_id = self.connector_id.strip()
if not connector_id:
@@ -94,6 +129,7 @@ class ExternalObjectReference:
"object_type": self.object_type,
"object_id": self.object_id,
"maturity": self.maturity,
"authority_mode": self.authority_mode,
"connector_id": self.connector_id,
"canonical_url": self.canonical_url,
"version": self.version,
@@ -133,5 +169,7 @@ __all__ = [
"ExternalReferenceValidationError",
"INTEGRATION_MATURITY_ORDER",
"IntegrationMaturity",
"SOURCE_AUTHORITY_MODES",
"SourceAuthorityMode",
"integration_maturity_rank",
]
+122
View File
@@ -0,0 +1,122 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
CAPABILITY_CONNECTORS_FEEDS = "connectors.feeds"
FeedFormat = Literal["rss", "atom"]
FeedVisibility = Literal["public", "tenant", "private"]
class FeedCapabilityError(ValueError):
"""Stable error raised by feed transport implementations."""
@dataclass(frozen=True, slots=True)
class FeedEntry:
id: str
title: str
url: str | None = None
summary: str | None = None
content: str | None = None
author: str | None = None
published_at: datetime | None = None
updated_at: datetime | None = None
categories: tuple[str, ...] = ()
enclosures: tuple[Mapping[str, object], ...] = ()
visibility: FeedVisibility = "public"
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class FeedDocument:
format: FeedFormat
title: str
source_url: str
entries: tuple[FeedEntry, ...]
description: str | None = None
home_url: str | None = None
language: str | None = None
updated_at: datetime | None = None
acquired_at: datetime | None = None
fresh_until: datetime | None = None
etag: str | None = None
last_modified: str | None = None
content_type: str | None = None
sha256: str = ""
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class FeedRenderRequest:
format: FeedFormat
title: str
feed_url: str
home_url: str
entries: tuple[FeedEntry, ...]
description: str | None = None
language: str | None = None
allowed_visibilities: frozenset[FeedVisibility] = frozenset({"public"})
@dataclass(frozen=True, slots=True)
class FeedRenderResult:
format: FeedFormat
content_type: str
body: bytes
included_entries: int
excluded_entries: int
@runtime_checkable
class FeedProvider(Protocol):
def fetch(
self,
url: str,
*,
timeout: float = 15,
max_entries: int = 2_000,
) -> FeedDocument:
...
def parse(
self,
content: bytes,
*,
source_url: str,
content_type: str | None = None,
max_entries: int = 2_000,
) -> FeedDocument:
...
def render(self, request: FeedRenderRequest) -> FeedRenderResult:
...
def feed_provider(registry: object | None) -> FeedProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(CAPABILITY_CONNECTORS_FEEDS)
):
return None
provider = registry.capability(CAPABILITY_CONNECTORS_FEEDS)
return provider if isinstance(provider, FeedProvider) else None
__all__ = [
"CAPABILITY_CONNECTORS_FEEDS",
"FeedCapabilityError",
"FeedDocument",
"FeedEntry",
"FeedFormat",
"FeedProvider",
"FeedRenderRequest",
"FeedRenderResult",
"FeedVisibility",
"feed_provider",
]
+39
View File
@@ -1,13 +1,52 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable
from govoplan_core.core.access import ResourceAccessExplanationProvider
CAPABILITY_FILES_ACCESS = "files.access"
CAPABILITY_FILES_ARTIFACT_STORE = "files.artifact_store"
@dataclass(frozen=True, slots=True)
class ManagedArtifactWriteRequest:
filename: str
payload: bytes
content_type: str
folder: str = "Generated"
description: str | None = None
idempotency_key: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ManagedArtifactRef:
file_asset_id: str
file_version_id: str
filename: str
display_path: str
content_type: str
size_bytes: int
sha256: str
provenance: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class FileAccessProvider(ResourceAccessExplanationProvider, Protocol):
"""Resource-level access explanation provider for Files-owned resources."""
@runtime_checkable
class ManagedArtifactStore(Protocol):
"""Store generated module artifacts without exposing Files internals."""
def store_artifact(
self,
session: object,
principal: object,
*,
request: ManagedArtifactWriteRequest,
) -> ManagedArtifactRef: ...
+532
View File
@@ -0,0 +1,532 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import StrEnum
import hashlib
import hmac
import json
import re
import secrets
from typing import Any
from uuid import uuid4
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, UniqueConstraint, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Mapped, Session, mapped_column
from govoplan_core.audit.logging import audit_event
from govoplan_core.core.access import (
FirstAdminProvisioner,
FirstAdminProvisioningError,
FirstSystemAdministratorRef,
)
from govoplan_core.db.base import Base, TimestampMixin, utcnow
from govoplan_core.tenancy.scope import Tenant
_TENANT_SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
class FirstAdminEnrollmentState(StrEnum):
INACTIVE = "inactive"
ACTIVE = "active"
CONSUMED = "consumed"
REVOKED = "revoked"
class FirstAdminEnrollmentError(RuntimeError):
pass
class FirstAdminEnrollmentUnavailable(FirstAdminEnrollmentError):
pass
class FirstAdminEnrollmentCredentialError(FirstAdminEnrollmentError):
pass
class FirstAdminEnrollmentConflict(FirstAdminEnrollmentError):
pass
class FirstAdminEnrollment(Base, TimestampMixin):
__tablename__ = "core_first_admin_enrollments"
installation_id: Mapped[str] = mapped_column(String(100), primary_key=True)
state: Mapped[str] = mapped_column(
String(24),
default=FirstAdminEnrollmentState.INACTIVE.value,
nullable=False,
index=True,
)
generation: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
token_sha256: Mapped[str | None] = mapped_column(String(64))
token_fingerprint: Mapped[str | None] = mapped_column(String(16))
issued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
consumed_account_id: Mapped[str | None] = mapped_column(String(36))
consumed_membership_id: Mapped[str | None] = mapped_column(String(36))
consumed_tenant_id: Mapped[str | None] = mapped_column(String(36))
consumed_email: Mapped[str | None] = mapped_column(String(320))
consumed_display_name: Mapped[str | None] = mapped_column(String(255))
consumed_request_sha256: Mapped[str | None] = mapped_column(String(64))
issue_reason: Mapped[str | None] = mapped_column(String(500))
event_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
evidence_head_sha256: Mapped[str | None] = mapped_column(String(64))
class FirstAdminEnrollmentEvent(Base):
__tablename__ = "core_first_admin_enrollment_events"
__table_args__ = (
UniqueConstraint(
"installation_id",
"sequence",
name="uq_core_first_admin_enrollment_event_sequence",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid4()),
)
installation_id: Mapped[str] = mapped_column(
ForeignKey(
"core_first_admin_enrollments.installation_id",
ondelete="CASCADE",
),
nullable=False,
index=True,
)
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
event_type: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
generation: Mapped[int] = mapped_column(Integer, nullable=False)
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
previous_sha256: Mapped[str | None] = mapped_column(String(64))
event_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utcnow,
nullable=False,
)
@dataclass(frozen=True, slots=True)
class IssuedFirstAdminCredential:
secret: str
fingerprint: str
generation: int
expires_at: datetime
@dataclass(frozen=True, slots=True)
class FirstAdminEnrollmentStatus:
enrollment_required: bool
credential_active: bool
state: str
generation: int
expires_at: datetime | None
completed_account_id: str | None
readiness: dict[str, bool]
@dataclass(frozen=True, slots=True)
class FirstAdminEnrollmentResult:
administrator: FirstSystemAdministratorRef
replayed: bool
def issue_first_admin_credential(
session: Session,
*,
installation_id: str,
provisioner: FirstAdminProvisioner,
ttl_seconds: int,
reason: str,
replace_active: bool = False,
now: datetime | None = None,
) -> IssuedFirstAdminCredential:
current_time = _utc(now)
if ttl_seconds < 60 or ttl_seconds > 24 * 60 * 60:
raise ValueError("First-admin enrollment expiry must be between 60 seconds and 24 hours.")
if provisioner.has_durable_system_administrator(session):
raise FirstAdminEnrollmentUnavailable(
"A durable system administrator already exists. Bootstrap enrollment is disabled."
)
enrollment = _locked_enrollment(session, installation_id)
if (
enrollment.state == FirstAdminEnrollmentState.ACTIVE.value
and _is_future(enrollment.expires_at, current_time)
and not replace_active
):
raise FirstAdminEnrollmentConflict(
"An unexpired first-admin credential already exists. Use the recovery command to rotate it."
)
secret = secrets.token_urlsafe(48)
token_sha256 = _secret_sha256(secret)
fingerprint = token_sha256[:12]
expires_at = current_time + timedelta(seconds=ttl_seconds)
generation = enrollment.generation + 1
if enrollment.state == FirstAdminEnrollmentState.ACTIVE.value:
_append_event(
session,
enrollment,
event_type="credential_revoked",
generation=enrollment.generation,
created_at=current_time,
evidence={"reason": "local_operator_recovery"},
)
enrollment.state = FirstAdminEnrollmentState.ACTIVE.value
enrollment.generation = generation
enrollment.token_sha256 = token_sha256
enrollment.token_fingerprint = fingerprint
enrollment.issued_at = current_time
enrollment.expires_at = expires_at
enrollment.consumed_at = None
enrollment.consumed_account_id = None
enrollment.consumed_membership_id = None
enrollment.consumed_tenant_id = None
enrollment.consumed_email = None
enrollment.consumed_display_name = None
enrollment.consumed_request_sha256 = None
enrollment.issue_reason = _bounded_reason(reason)
session.add(enrollment)
_append_event(
session,
enrollment,
event_type="credential_issued",
generation=generation,
created_at=current_time,
evidence={
"fingerprint": fingerprint,
"expires_at": expires_at.isoformat(),
"reason": enrollment.issue_reason,
},
)
audit_event(
session,
tenant_id=None,
scope="system",
action="access.first_admin_enrollment.issued",
object_type="first_admin_enrollment",
object_id=installation_id,
details={
"generation": generation,
"fingerprint": fingerprint,
"expires_at": expires_at.isoformat(),
"reason": enrollment.issue_reason,
},
)
return IssuedFirstAdminCredential(
secret=secret,
fingerprint=fingerprint,
generation=generation,
expires_at=expires_at,
)
def first_admin_enrollment_status(
session: Session,
*,
installation_id: str,
provisioner: FirstAdminProvisioner,
now: datetime | None = None,
) -> FirstAdminEnrollmentStatus:
current_time = _utc(now)
administrator_exists = provisioner.has_durable_system_administrator(session)
enrollment = session.get(FirstAdminEnrollment, installation_id)
state = enrollment.state if enrollment is not None else FirstAdminEnrollmentState.INACTIVE.value
active = bool(
not administrator_exists
and enrollment is not None
and state == FirstAdminEnrollmentState.ACTIVE.value
and enrollment.token_sha256
and _is_future(enrollment.expires_at, current_time)
)
if (
not administrator_exists
and enrollment is not None
and state == FirstAdminEnrollmentState.ACTIVE.value
and not active
):
state = "expired"
return FirstAdminEnrollmentStatus(
enrollment_required=not administrator_exists,
credential_active=active,
state="completed" if administrator_exists else state,
generation=enrollment.generation if enrollment is not None else 0,
expires_at=enrollment.expires_at if enrollment is not None else None,
completed_account_id=(
enrollment.consumed_account_id if enrollment is not None else None
),
readiness={
"database": True,
"access_capability": True,
"administrator_absent": not administrator_exists,
},
)
def consume_first_admin_credential(
session: Session,
*,
installation_id: str,
provisioner: FirstAdminProvisioner,
secret: str,
email: str,
display_name: str | None,
password: str,
tenant_slug: str,
tenant_name: str,
now: datetime | None = None,
) -> FirstAdminEnrollmentResult:
current_time = _utc(now)
normalized_email = email.strip().casefold()
clean_display_name = display_name.strip() if display_name and display_name.strip() else None
clean_tenant_slug = tenant_slug.strip().casefold()
clean_tenant_name = tenant_name.strip()
if not normalized_email or "@" not in normalized_email:
raise FirstAdminEnrollmentConflict("Enter a valid administrator email address.")
if len(password) < 12:
raise FirstAdminEnrollmentConflict("The administrator password must contain at least 12 characters.")
if not _TENANT_SLUG_RE.fullmatch(clean_tenant_slug):
raise FirstAdminEnrollmentConflict(
"The initial tenant slug may contain lowercase letters, numbers, and single hyphens."
)
if not clean_tenant_name:
raise FirstAdminEnrollmentConflict("Enter a name for the initial tenant.")
request_sha256 = _request_sha256(
email=normalized_email,
display_name=clean_display_name,
tenant_slug=clean_tenant_slug,
tenant_name=clean_tenant_name,
)
supplied_sha256 = _secret_sha256(secret)
enrollment = session.execute(
select(FirstAdminEnrollment)
.where(FirstAdminEnrollment.installation_id == installation_id)
.with_for_update()
).scalar_one_or_none()
if enrollment is None:
raise FirstAdminEnrollmentCredentialError("First-admin enrollment is not active.")
if enrollment.state == FirstAdminEnrollmentState.CONSUMED.value:
if (
enrollment.token_sha256
and hmac.compare_digest(enrollment.token_sha256, supplied_sha256)
and enrollment.consumed_request_sha256 == request_sha256
and enrollment.consumed_account_id
and enrollment.consumed_email
):
return FirstAdminEnrollmentResult(
administrator=FirstSystemAdministratorRef(
account_id=enrollment.consumed_account_id,
email=enrollment.consumed_email,
display_name=enrollment.consumed_display_name,
membership_id=enrollment.consumed_membership_id,
tenant_id=enrollment.consumed_tenant_id,
),
replayed=True,
)
raise FirstAdminEnrollmentCredentialError("The first-admin credential has already been used.")
if enrollment.state != FirstAdminEnrollmentState.ACTIVE.value or not enrollment.token_sha256:
raise FirstAdminEnrollmentCredentialError("First-admin enrollment is not active.")
if not _is_future(enrollment.expires_at, current_time):
raise FirstAdminEnrollmentCredentialError(
"The first-admin credential has expired. A local operator must issue a replacement."
)
if not hmac.compare_digest(enrollment.token_sha256, supplied_sha256):
raise FirstAdminEnrollmentCredentialError("The first-admin credential is invalid.")
if provisioner.has_durable_system_administrator(session):
raise FirstAdminEnrollmentUnavailable(
"A durable system administrator already exists. Bootstrap enrollment is disabled."
)
tenant = session.execute(
select(Tenant).where(Tenant.slug == clean_tenant_slug).with_for_update()
).scalar_one_or_none()
if tenant is None:
tenant = Tenant(
slug=clean_tenant_slug,
name=clean_tenant_name,
default_locale="en",
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",
]
+321
View File
@@ -0,0 +1,321 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
CAPABILITY_IDENTITY_TRUST_DIRECTORY = "identity_trust.directory"
CAPABILITY_IDENTITY_TRUST_ASSURANCE = "identity_trust.assurance"
IDENTITY_TRUST_CONTRACT_VERSION = "1"
DeviceKeyPurpose = Literal["encryption", "signing", "encryption_and_signing"]
DeviceKeyStatus = Literal["active", "revoked", "expired"]
TrustSubjectKind = Literal[
"identity",
"account",
"function",
"postbox",
"external_recipient",
]
_PRIVATE_JWK_FIELDS = frozenset({"d", "p", "q", "dp", "dq", "qi", "oth", "k"})
@dataclass(frozen=True, slots=True)
class DeviceKeyRegistration:
tenant_id: str
identity_id: str
account_id: str
device_id: str
key_id: str
algorithm: str
public_jwk: Mapping[str, object]
purpose: DeviceKeyPurpose = "encryption"
assurance_level: str = "software"
attestation_ref: str | None = None
expires_at: datetime | None = None
idempotency_key: str = ""
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract(self.contract_version)
for label, value in (
("tenant id", self.tenant_id),
("identity id", self.identity_id),
("account id", self.account_id),
("device id", self.device_id),
("key id", self.key_id),
("algorithm", self.algorithm),
("idempotency key", self.idempotency_key),
):
_require_text(value, label)
if not self.public_jwk or _PRIVATE_JWK_FIELDS & set(self.public_jwk):
raise ValueError("Only a bounded public JWK may be registered")
@dataclass(frozen=True, slots=True)
class DeviceKeyRef:
tenant_id: str
identity_id: str
account_id: str
device_id: str
key_id: str
algorithm: str
public_jwk: Mapping[str, object]
purpose: DeviceKeyPurpose
assurance_level: str
status: DeviceKeyStatus
epoch: int
registered_at: datetime
attestation_ref: str | None = None
expires_at: datetime | None = None
revoked_at: datetime | None = None
revocation_reason: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
@dataclass(frozen=True, slots=True)
class KeyEpochRotationRequest:
tenant_id: str
subject_kind: TrustSubjectKind
subject_id: str
reason: str
access_decision_ref: str
idempotency_key: str
history_policy: str = "all_retained"
previous_epoch: int | None = None
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract(self.contract_version)
for label, value in (
("tenant id", self.tenant_id),
("subject id", self.subject_id),
("reason", self.reason),
("access decision reference", self.access_decision_ref),
("idempotency key", self.idempotency_key),
):
_require_text(value, label)
@dataclass(frozen=True, slots=True)
class KeyEpochRef:
tenant_id: str
subject_kind: TrustSubjectKind
subject_id: str
epoch: int
state: Literal["active", "superseded", "revoked"]
history_policy: str
effective_at: datetime
previous_epoch: int | None = None
reason: str | None = None
access_decision_ref: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
@dataclass(frozen=True, slots=True)
class KeyAccessRequest:
tenant_id: str
account_id: str
device_key_id: str
subject_kind: TrustSubjectKind
subject_id: str
key_epoch: int
access_decision_ref: str
purpose: str
requested_at: datetime
function_assignment_id: str | None = None
delegation_id: str | None = None
resource_ref: str | None = None
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract(self.contract_version)
if self.key_epoch < 1:
raise ValueError("Key epoch must be positive")
for label, value in (
("tenant id", self.tenant_id),
("account id", self.account_id),
("device key id", self.device_key_id),
("subject id", self.subject_id),
("access decision reference", self.access_decision_ref),
("purpose", self.purpose),
):
_require_text(value, label)
@dataclass(frozen=True, slots=True)
class KeyAccessDecision:
allowed: bool
decision_ref: str
reason: str
device_key: DeviceKeyRef | None = None
epoch: KeyEpochRef | None = None
audit_event_ref: str | None = None
requirements: tuple[str, ...] = ()
provenance: Mapping[str, object] = field(default_factory=dict)
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
@dataclass(frozen=True, slots=True)
class AssuranceCheckRequest:
tenant_id: str
account_id: str
purpose: str
minimum_level: str
evidence_ref: str
evaluated_at: datetime
maximum_age_seconds: int = 300
device_key_id: str | None = None
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract(self.contract_version)
if self.maximum_age_seconds < 1:
raise ValueError("Assurance maximum age must be positive")
for label, value in (
("tenant id", self.tenant_id),
("account id", self.account_id),
("purpose", self.purpose),
("minimum level", self.minimum_level),
("evidence reference", self.evidence_ref),
):
_require_text(value, label)
@dataclass(frozen=True, slots=True)
class AssuranceDecision:
allowed: bool
reason: str
assurance_level: str | None = None
evidence_ref: str | None = None
verified_at: datetime | None = None
expires_at: datetime | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
@runtime_checkable
class IdentityTrustDirectory(Protocol):
def register_device_key(
self,
session: object,
principal: object,
*,
request: DeviceKeyRegistration,
) -> DeviceKeyRef: ...
def revoke_device_key(
self,
session: object,
principal: object,
*,
tenant_id: str,
key_id: str,
expected_epoch: int,
reason: str,
) -> DeviceKeyRef: ...
def list_device_keys(
self,
session: object,
principal: object,
*,
tenant_id: str,
account_id: str,
active_only: bool = True,
) -> tuple[DeviceKeyRef, ...]: ...
def rotate_epoch(
self,
session: object,
principal: object,
*,
request: KeyEpochRotationRequest,
) -> KeyEpochRef: ...
def resolve_epoch(
self,
session: object,
*,
tenant_id: str,
subject_kind: TrustSubjectKind,
subject_id: str,
epoch: int | None = None,
) -> KeyEpochRef | None: ...
def decide_key_access(
self,
session: object,
principal: object,
*,
request: KeyAccessRequest,
) -> KeyAccessDecision: ...
@runtime_checkable
class IdentityTrustAssurance(Protocol):
def verify_assurance(
self,
session: object,
principal: object,
*,
request: AssuranceCheckRequest,
) -> AssuranceDecision: ...
def identity_trust_directory(
registry: object | None,
) -> IdentityTrustDirectory | None:
capability = _capability(registry, CAPABILITY_IDENTITY_TRUST_DIRECTORY)
return capability if isinstance(capability, IdentityTrustDirectory) else None
def identity_trust_assurance(
registry: object | None,
) -> IdentityTrustAssurance | None:
capability = _capability(registry, CAPABILITY_IDENTITY_TRUST_ASSURANCE)
return capability if isinstance(capability, IdentityTrustAssurance) else None
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
def _validate_contract(value: str) -> None:
if value != IDENTITY_TRUST_CONTRACT_VERSION:
raise ValueError("Unsupported identity-trust contract version")
def _require_text(value: str, label: str) -> None:
if not value.strip():
raise ValueError(f"{label.capitalize()} is required")
__all__ = [
"AssuranceCheckRequest",
"AssuranceDecision",
"CAPABILITY_IDENTITY_TRUST_ASSURANCE",
"CAPABILITY_IDENTITY_TRUST_DIRECTORY",
"DeviceKeyRef",
"DeviceKeyRegistration",
"IdentityTrustAssurance",
"IdentityTrustDirectory",
"KeyAccessDecision",
"KeyAccessRequest",
"KeyEpochRef",
"KeyEpochRotationRequest",
"identity_trust_assurance",
"identity_trust_directory",
]
+149 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
@@ -10,9 +10,12 @@ IDM_MODULE_ID = "idm"
CAPABILITY_IDM_DIRECTORY = "idm.directory"
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS = "idm.function_assignments"
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE = "idm.assignment_lifecycle"
CAPABILITY_IDM_RELATIONSHIPS = "idm.relationships"
IdmStatus = Literal["active", "inactive", "suspended"]
OrganizationFunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
TypedGroupStatus = Literal["active", "inactive"]
IdentityRelationshipStatus = Literal["active", "revoked"]
@dataclass(frozen=True, slots=True)
@@ -44,6 +47,78 @@ class OrganizationFunctionIncumbencyRef:
return not self.assignments
@dataclass(frozen=True, slots=True)
class TypedGroupRef:
"""Provider-neutral IDM group fact scoped to one tenant."""
id: str
tenant_id: str
key: str
name: str
group_type: str
description: str | None = None
status: TypedGroupStatus = "active"
source_provider: str = "local"
source_resource_type: str | None = None
source_resource_id: str | None = None
source_revision: str | None = None
properties: Mapping[str, object] = field(default_factory=dict)
provenance: Mapping[str, object] = field(default_factory=dict)
revision: int = 1
@dataclass(frozen=True, slots=True)
class IdentityRelationshipRef:
"""An effective-dated relationship from an identity to a typed target."""
id: str
tenant_id: str
relationship_kind: str
subject_identity_id: str
target_group_id: str | None = None
related_identity_id: str | None = None
role: str | None = None
valid_from: datetime | None = None
valid_until: datetime | None = None
status: IdentityRelationshipStatus = "active"
revoked_at: datetime | None = None
revoked_by: str | None = None
revocation_reason: str | None = None
source_provider: str = "local"
source_resource_type: str | None = None
source_resource_id: str | None = None
source_revision: str | None = None
properties: Mapping[str, object] = field(default_factory=dict)
provenance: Mapping[str, object] = field(default_factory=dict)
revision: int = 1
@dataclass(frozen=True, slots=True)
class IdentityRelationshipDecisionRef:
relationship: IdentityRelationshipRef
included: bool
code: str
explanation: str
identity_status: IdmStatus | None = None
@dataclass(frozen=True, slots=True)
class TypedGroupMembershipResolutionRef:
group: TypedGroupRef
effective_at: datetime
decisions: tuple[IdentityRelationshipDecisionRef, ...] = ()
@property
def identity_ids(self) -> tuple[str, ...]:
return tuple(
dict.fromkeys(
item.relationship.subject_identity_id
for item in self.decisions
if item.included
)
)
@runtime_checkable
class IdmDirectory(Protocol):
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
@@ -109,6 +184,79 @@ class IdmFunctionAssignmentDirectory(Protocol):
...
@runtime_checkable
class IdmRelationshipDirectory(Protocol):
"""Tenant-safe forward/reverse lookup for typed IDM relationships."""
def get_typed_group(
self,
group_id: str,
*,
tenant_id: str | None = None,
) -> TypedGroupRef | None:
...
def list_typed_groups(
self,
*,
tenant_id: str,
query: str | None = None,
group_types: Sequence[str] = (),
include_inactive: bool = False,
limit: int = 100,
) -> Sequence[TypedGroupRef]:
...
def identity_relationships_for_identity(
self,
identity_id: str,
*,
tenant_id: str,
effective_at: datetime | None = None,
relationship_kinds: Sequence[str] = (),
) -> Sequence[IdentityRelationshipRef]:
...
def identity_relationships_for_identities(
self,
identity_ids: Sequence[str],
*,
tenant_id: str,
effective_at: datetime | None = None,
relationship_kinds: Sequence[str] = (),
) -> Mapping[str, Sequence[IdentityRelationshipRef]]:
...
def identity_relationships_for_group(
self,
group_id: str,
*,
tenant_id: str,
effective_at: datetime | None = None,
relationship_kinds: Sequence[str] = (),
) -> Sequence[IdentityRelationshipRef]:
...
def identity_relationships_for_groups(
self,
group_ids: Sequence[str],
*,
tenant_id: str,
effective_at: datetime | None = None,
relationship_kinds: Sequence[str] = (),
) -> Mapping[str, Sequence[IdentityRelationshipRef]]:
...
def resolve_typed_group_memberships(
self,
group_ids: Sequence[str],
*,
tenant_id: str,
effective_at: datetime | None = None,
relationship_kinds: Sequence[str] = ("member",),
) -> Mapping[str, TypedGroupMembershipResolutionRef]:
...
@runtime_checkable
class IdmAssignmentLifecycle(Protocol):
"""Worker boundary for time-driven function-assignment transitions."""
+426 -57
View File
@@ -90,7 +90,9 @@ class _ConfigIssueCollector:
def add(self, level: ConfigIssueLevel, key: str, message: str, action: str) -> None:
if self.strict and level == "warning":
level = "error"
self.issues.append(ConfigIssue(level=level, key=key, message=message, action=action))
self.issues.append(
ConfigIssue(level=level, key=key, message=message, action=action)
)
_LOCAL_PROFILES = {"dev", "local", "local-dev", "test"}
@@ -120,9 +122,15 @@ def generate_master_key() -> str:
return Fernet.generate_key().decode("ascii")
def env_template(*, profile: str = "self-hosted", generate_secrets: bool = False) -> str:
def env_template(
*, profile: str = "self-hosted", generate_secrets: bool = False
) -> str:
clean_profile = normalize_install_profile(profile)
master_key = generate_master_key() if generate_secrets else "<generate-with-govoplan-config-env-template-generate-secrets>"
master_key = (
generate_master_key()
if generate_secrets
else "<generate-with-govoplan-config-env-template-generate-secrets>"
)
if clean_profile == "production-like":
return _production_like_env_template(master_key)
return _self_hosted_env_template(master_key)
@@ -144,14 +152,19 @@ def validate_runtime_configuration(
_validate_async_and_auth_settings(env, runtime, collector)
_validate_cors_settings(env, runtime, collector)
_validate_file_storage_settings(env, runtime, collector)
_validate_shared_state_settings(env, collector)
_validate_outbound_connector_policy(env, runtime, collector)
_validate_module_catalog_trust(env, runtime, collector)
return ConfigValidationResult(profile=runtime.name, issues=tuple(collector.issues))
def _runtime_profile(env: Mapping[str, str], *, profile: str | None) -> _RuntimeProfile:
clean_profile = normalize_install_profile(profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV"))
production = clean_profile in _PRODUCTION_PROFILES or env.get("APP_ENV", "").strip().lower() in {"prod", "production"}
clean_profile = normalize_install_profile(
profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV")
)
production = clean_profile in _PRODUCTION_PROFILES or env.get(
"APP_ENV", ""
).strip().lower() in {"prod", "production"}
production_like = production or clean_profile in _PRODUCTION_LIKE_PROFILES
return _RuntimeProfile(
name=clean_profile,
@@ -161,86 +174,222 @@ def _runtime_profile(env: Mapping[str, str], *, profile: str | None) -> _Runtime
)
def _validate_app_env(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
def _validate_app_env(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
app_env = _clean(env.get("APP_ENV"))
if not app_env and runtime.production_like:
collector.add("error", "APP_ENV", "APP_ENV is missing for a production-like install.", "Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.")
collector.add(
"error",
"APP_ENV",
"APP_ENV is missing for a production-like install.",
"Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.",
)
elif app_env.lower() in {"dev", "test", "local"} and runtime.production_like:
collector.add("error", "APP_ENV", f"APP_ENV={app_env!r} is not valid for profile {runtime.name!r}.", "Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.")
collector.add(
"error",
"APP_ENV",
f"APP_ENV={app_env!r} is not valid for profile {runtime.name!r}.",
"Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.",
)
def _validate_database_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
def _validate_database_settings(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
database_url = _clean(env.get("DATABASE_URL"))
if not database_url:
collector.add("error", "DATABASE_URL", "DATABASE_URL is missing.", "Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.")
collector.add(
"error",
"DATABASE_URL",
"DATABASE_URL is missing.",
"Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.",
)
return
backend = _database_backend(database_url)
if backend is None:
collector.add("error", "DATABASE_URL", "DATABASE_URL is not a valid SQLAlchemy URL.", "Use a value like postgresql+psycopg://user:password@host:5432/database.")
collector.add(
"error",
"DATABASE_URL",
"DATABASE_URL is not a valid SQLAlchemy URL.",
"Use a value like postgresql+psycopg://user:password@host:5432/database.",
)
return
if backend == "sqlite" and runtime.production_like:
collector.add("error", "DATABASE_URL", "SQLite is only supported for disposable local development.", "Use PostgreSQL for production-like and self-hosted installs.")
collector.add(
"error",
"DATABASE_URL",
"SQLite is only supported for disposable local development.",
"Use PostgreSQL for production-like and self-hosted installs.",
)
elif backend != "postgresql" and runtime.production:
collector.add("warning", "DATABASE_URL", f"Database backend {backend!r} is not the preferred production target.", "Use PostgreSQL unless this deployment has an explicit support decision.")
collector.add(
"warning",
"DATABASE_URL",
f"Database backend {backend!r} is not the preferred production target.",
"Use PostgreSQL unless this deployment has an explicit support decision.",
)
if backend == "postgresql" and not _clean(env.get("GOVOPLAN_DATABASE_URL_PGTOOLS")):
collector.add("warning", "GOVOPLAN_DATABASE_URL_PGTOOLS", "PostgreSQL backup/restore tools URL is missing.", "Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.")
collector.add(
"warning",
"GOVOPLAN_DATABASE_URL_PGTOOLS",
"PostgreSQL backup/restore tools URL is missing.",
"Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.",
)
def _validate_master_key(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
def _validate_master_key(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
master_key = _clean(env.get("MASTER_KEY_B64"))
if not master_key and not runtime.local:
collector.add("error", "MASTER_KEY_B64", "MASTER_KEY_B64 is required outside local dev/test.", "Generate a Fernet key with `govoplan-config env-template --generate-secrets` or `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` and store it in deployment secrets.")
collector.add(
"error",
"MASTER_KEY_B64",
"MASTER_KEY_B64 is required outside local dev/test.",
"Generate a Fernet key with `govoplan-config env-template --generate-secrets` or `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` and store it in deployment secrets.",
)
return
if not master_key:
return
error = _master_key_error(master_key)
if error:
collector.add("error", "MASTER_KEY_B64", error, "Replace MASTER_KEY_B64 with a Fernet key or base64-encoded 32-byte key.")
collector.add(
"error",
"MASTER_KEY_B64",
error,
"Replace MASTER_KEY_B64 with a Fernet key or base64-encoded 32-byte key.",
)
elif "change-me" in master_key.lower() or "generate" in master_key.lower():
collector.add("error", "MASTER_KEY_B64", "MASTER_KEY_B64 still looks like a placeholder.", "Generate a real deployment key and store it outside git.")
collector.add(
"error",
"MASTER_KEY_B64",
"MASTER_KEY_B64 still looks like a placeholder.",
"Generate a real deployment key and store it outside git.",
)
def _validate_enabled_modules(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
def _validate_enabled_modules(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
enabled_modules = _csv(env.get("ENABLED_MODULES"))
if not enabled_modules and runtime.production_like:
collector.add("error", "ENABLED_MODULES", "ENABLED_MODULES is missing.", "Set ENABLED_MODULES explicitly so startup module composition is intentional.")
collector.add(
"error",
"ENABLED_MODULES",
"ENABLED_MODULES is missing.",
"Set ENABLED_MODULES explicitly so startup module composition is intentional.",
)
elif "access" not in enabled_modules and runtime.production_like:
collector.add("error", "ENABLED_MODULES", "The access module is not enabled.", "Include `access` unless this deployment has a replacement auth/principal provider.")
collector.add(
"error",
"ENABLED_MODULES",
"The access module is not enabled.",
"Include `access` unless this deployment has a replacement auth/principal provider.",
)
elif enabled_modules and "admin" not in enabled_modules and runtime.production_like:
collector.add("warning", "ENABLED_MODULES", "The admin module is not enabled.", "Keep `admin` enabled for operator UI unless this is a deliberately headless install.")
collector.add(
"warning",
"ENABLED_MODULES",
"The admin module is not enabled.",
"Keep `admin` enabled for operator UI unless this is a deliberately headless install.",
)
def _validate_async_and_auth_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
def _validate_async_and_auth_settings(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
if _truthy(env.get("CELERY_ENABLED")) and not _clean(env.get("REDIS_URL")):
collector.add("error", "REDIS_URL", "CELERY_ENABLED=true but REDIS_URL is missing.", "Set REDIS_URL to the Redis broker/result backend used by workers.")
collector.add(
"error",
"REDIS_URL",
"CELERY_ENABLED=true but REDIS_URL is missing.",
"Set REDIS_URL to the Redis broker/result backend used by workers.",
)
if runtime.production and _truthy(env.get("DEV_BOOTSTRAP_ENABLED")):
collector.add("error", "DEV_BOOTSTRAP_ENABLED", "Development bootstrap is enabled in production.", "Set DEV_BOOTSTRAP_ENABLED=false and create first administrators through the controlled bootstrap path.")
collector.add(
"error",
"DEV_BOOTSTRAP_ENABLED",
"Development bootstrap is enabled in production.",
"Set DEV_BOOTSTRAP_ENABLED=false and create first administrators through the controlled bootstrap path.",
)
if runtime.production and not _truthy(env.get("AUTH_COOKIE_SECURE")):
collector.add("error", "AUTH_COOKIE_SECURE", "Secure auth cookies are disabled for production.", "Set AUTH_COOKIE_SECURE=true behind HTTPS.")
collector.add(
"error",
"AUTH_COOKIE_SECURE",
"Secure auth cookies are disabled for production.",
"Set AUTH_COOKIE_SECURE=true behind HTTPS.",
)
def _validate_cors_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
def _validate_cors_settings(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
cors_origins = _csv(env.get("CORS_ORIGINS"))
if runtime.production_like and not cors_origins:
collector.add("error", "CORS_ORIGINS", "CORS_ORIGINS is missing.", "Set CORS_ORIGINS to the exact WebUI origin or origins.")
collector.add(
"error",
"CORS_ORIGINS",
"CORS_ORIGINS is missing.",
"Set CORS_ORIGINS to the exact WebUI origin or origins.",
)
elif "*" in cors_origins and runtime.production_like:
collector.add("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.")
collector.add(
"error",
"CORS_ORIGINS",
"Wildcard CORS is not allowed for production-like installs.",
"Replace `*` with exact HTTPS/WebUI origins.",
)
elif runtime.production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
collector.add("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.")
collector.add(
"warning",
"CORS_ORIGINS",
"CORS_ORIGINS still contains only local development origins.",
"Set CORS_ORIGINS to the deployed WebUI origin.",
)
trusted_hosts = _csv(env.get("GOVOPLAN_TRUSTED_HOSTS"))
if runtime.production_like and not trusted_hosts:
collector.add("error", "GOVOPLAN_TRUSTED_HOSTS", "Trusted HTTP hosts are not configured.", "Set GOVOPLAN_TRUSTED_HOSTS to the exact API host names accepted by this deployment.")
collector.add(
"error",
"GOVOPLAN_TRUSTED_HOSTS",
"Trusted HTTP hosts are not configured.",
"Set GOVOPLAN_TRUSTED_HOSTS to the exact API host names accepted by this deployment.",
)
elif "*" in trusted_hosts and runtime.production_like:
collector.add("error", "GOVOPLAN_TRUSTED_HOSTS", "Wildcard trusted hosts are not allowed for production-like installs.", "Replace `*` with exact host names or narrowly scoped `*.example.org` entries.")
collector.add(
"error",
"GOVOPLAN_TRUSTED_HOSTS",
"Wildcard trusted hosts are not allowed for production-like installs.",
"Replace `*` with exact host names or narrowly scoped `*.example.org` entries.",
)
forwarded_allow_ips = _csv(env.get("FORWARDED_ALLOW_IPS"))
if runtime.production_like and "*" in forwarded_allow_ips:
collector.add("error", "FORWARDED_ALLOW_IPS", "Proxy headers must not be trusted from every address.", "Set FORWARDED_ALLOW_IPS to the reverse proxy address or network passed to Uvicorn.")
collector.add(
"error",
"FORWARDED_ALLOW_IPS",
"Proxy headers must not be trusted from every address.",
"Set FORWARDED_ALLOW_IPS to the reverse proxy address or network passed to Uvicorn.",
)
def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
def _validate_file_storage_settings(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
storage_backend = _clean(env.get("FILE_STORAGE_BACKEND")) or "local"
deployment_managed_raw = _clean(env.get("FILE_STORAGE_S3_DEPLOYMENT_MANAGED")).lower()
if deployment_managed_raw and deployment_managed_raw not in {"true", "false", "1", "0", "yes", "no", "on", "off"}:
deployment_managed_raw = _clean(
env.get("FILE_STORAGE_S3_DEPLOYMENT_MANAGED")
).lower()
endpoint_trusted_raw = _clean(env.get("FILE_STORAGE_S3_ENDPOINT_TRUSTED")).lower()
if deployment_managed_raw and deployment_managed_raw not in {
"true",
"false",
"1",
"0",
"yes",
"no",
"on",
"off",
}:
collector.add(
"error",
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
@@ -248,13 +397,30 @@ def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimePro
"Set FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false, or let the supported installer manage Garage.",
)
deployment_managed = _truthy(deployment_managed_raw)
if endpoint_trusted_raw and endpoint_trusted_raw not in {
"true",
"false",
"1",
"0",
"yes",
"no",
"on",
"off",
}:
collector.add(
"error",
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
"External S3 endpoint trust must be an explicit boolean.",
"Set it only for a deployment-controlled HTTPS storage origin.",
)
endpoint_trusted = _truthy(endpoint_trusted_raw)
if storage_backend == "local":
if deployment_managed:
if deployment_managed or endpoint_trusted:
collector.add(
"error",
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
"Managed S3 trust cannot be enabled for local file storage.",
"Set FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false.",
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
"S3 endpoint trust cannot be enabled for local file storage.",
"Disable both S3 trust settings while FILE_STORAGE_BACKEND=local.",
)
_validate_local_file_storage(env, runtime, collector)
elif storage_backend == "s3":
@@ -262,16 +428,34 @@ def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimePro
env,
collector,
deployment_managed=deployment_managed,
endpoint_trusted=endpoint_trusted,
)
else:
collector.add("error", "FILE_STORAGE_BACKEND", f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.", "Use `local` or `s3`.")
collector.add(
"error",
"FILE_STORAGE_BACKEND",
f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.",
"Use `local` or `s3`.",
)
def _validate_local_file_storage(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
def _validate_local_file_storage(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
if not _clean(env.get("FILE_STORAGE_LOCAL_ROOT")) and runtime.production_like:
collector.add("error", "FILE_STORAGE_LOCAL_ROOT", "Local file storage root is missing.", "Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.")
collector.add(
"error",
"FILE_STORAGE_LOCAL_ROOT",
"Local file storage root is missing.",
"Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.",
)
elif runtime.production:
collector.add("warning", "FILE_STORAGE_BACKEND", "Production is configured for local file storage.", "Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.")
collector.add(
"warning",
"FILE_STORAGE_BACKEND",
"Production is configured for local file storage.",
"Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.",
)
def _validate_s3_file_storage(
@@ -279,10 +463,22 @@ def _validate_s3_file_storage(
collector: _ConfigIssueCollector,
*,
deployment_managed: bool,
endpoint_trusted: bool,
) -> None:
for key in ("FILE_STORAGE_S3_ENDPOINT_URL", "FILE_STORAGE_S3_REGION", "FILE_STORAGE_S3_ACCESS_KEY_ID", "FILE_STORAGE_S3_SECRET_ACCESS_KEY", "FILE_STORAGE_S3_BUCKET"):
for key in (
"FILE_STORAGE_S3_ENDPOINT_URL",
"FILE_STORAGE_S3_REGION",
"FILE_STORAGE_S3_ACCESS_KEY_ID",
"FILE_STORAGE_S3_SECRET_ACCESS_KEY",
"FILE_STORAGE_S3_BUCKET",
):
if not _clean(env.get(key)):
collector.add("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
collector.add(
"error",
key,
f"{key} is required when FILE_STORAGE_BACKEND=s3.",
"Configure all FILE_STORAGE_S3_* settings through deployment secrets.",
)
if (
deployment_managed
and _clean(env.get("FILE_STORAGE_S3_ENDPOINT_URL")) != "http://garage:3900"
@@ -293,6 +489,126 @@ def _validate_s3_file_storage(
"Installer-managed S3 trust is restricted to http://garage:3900.",
"Use the exact managed Garage endpoint or disable FILE_STORAGE_S3_DEPLOYMENT_MANAGED.",
)
if deployment_managed and endpoint_trusted:
collector.add(
"error",
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
"Managed Garage trust and external endpoint trust are mutually exclusive.",
"Use installer-managed Garage trust or one explicit external endpoint.",
)
if not deployment_managed and not endpoint_trusted:
collector.add(
"error",
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
"External S3 storage requires an explicit deployment trust decision.",
"Set FILE_STORAGE_S3_ENDPOINT_TRUSTED=true only for a deployment-controlled HTTPS origin.",
)
endpoint = _clean(env.get("FILE_STORAGE_S3_ENDPOINT_URL"))
if endpoint_trusted and not endpoint.lower().startswith("https://"):
collector.add(
"error",
"FILE_STORAGE_S3_ENDPOINT_URL",
"Deployment-trusted external S3 storage must use HTTPS.",
"Use an HTTPS storage origin with certificate verification.",
)
def _validate_shared_state_settings(
env: Mapping[str, str],
collector: _ConfigIssueCollector,
) -> None:
state_profile = (_clean(env.get("GOVOPLAN_STATE_PROFILE")) or "local").lower()
if state_profile not in {"local", "host-shared", "shared"}:
collector.add(
"error",
"GOVOPLAN_STATE_PROFILE",
f"Unsupported state profile {state_profile!r}.",
"Use `local` for one process per role, `host-shared` for one Compose host, or `shared` for a multi-host stateless tier.",
)
return
try:
api_replicas = int(_clean(env.get("GOVOPLAN_EXPECTED_API_REPLICAS")) or "1")
worker_replicas = int(
_clean(env.get("GOVOPLAN_EXPECTED_WORKER_REPLICAS")) or "0"
)
except ValueError:
collector.add(
"error",
"GOVOPLAN_EXPECTED_API_REPLICAS",
"Expected replica counts must be integers.",
"Set GOVOPLAN_EXPECTED_API_REPLICAS and GOVOPLAN_EXPECTED_WORKER_REPLICAS to non-negative integers.",
)
return
if api_replicas < 1 or worker_replicas < 0:
collector.add(
"error",
"GOVOPLAN_EXPECTED_API_REPLICAS",
"Expected replica counts are outside their supported range.",
"Configure at least one API replica and zero or more worker replicas.",
)
if state_profile == "local":
if api_replicas > 1 or worker_replicas > 1:
collector.add(
"error",
"GOVOPLAN_STATE_PROFILE",
"A local-state profile cannot safely run replicated API or worker nodes.",
"Use `host-shared` with one shared host volume, or `shared` with PostgreSQL, Redis, and S3-compatible object storage.",
)
return
installation_id = _clean(env.get("GOVOPLAN_INSTALLATION_ID"))
if not installation_id or (
state_profile == "shared" and installation_id == "govoplan-local"
):
collector.add(
"error",
"GOVOPLAN_INSTALLATION_ID",
"Shared-state deployments require a stable installation identifier.",
"Set one immutable deployment-wide GOVOPLAN_INSTALLATION_ID on every node.",
)
if _database_backend(_clean(env.get("DATABASE_URL"))) != "postgresql":
collector.add(
"error",
"DATABASE_URL",
"Shared-state deployments require PostgreSQL.",
"Point every API, scheduler, and worker node at the same logical PostgreSQL service.",
)
if not _clean(env.get("REDIS_URL")):
collector.add(
"error",
"REDIS_URL",
"Shared-state deployments require a common Redis service.",
"Configure the same Redis endpoint for all API and worker nodes.",
)
if (
state_profile == "shared"
and (_clean(env.get("FILE_STORAGE_BACKEND")) or "local").lower() != "s3"
):
collector.add(
"error",
"FILE_STORAGE_BACKEND",
"Shared-state deployments cannot use node-local object storage.",
"Set FILE_STORAGE_BACKEND=s3 and configure one shared S3-compatible bucket.",
)
try:
heartbeat = int(_clean(env.get("GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS")) or "15")
stale_after = int(
_clean(env.get("GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS")) or "60"
)
except ValueError:
collector.add(
"error",
"GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS",
"Runtime heartbeat and stale intervals must be integers.",
"Use a heartbeat interval shorter than one third of the stale interval.",
)
else:
if heartbeat < 2 or stale_after < max(10, heartbeat * 3):
collector.add(
"error",
"GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS",
"Runtime stale detection leaves insufficient room for missed heartbeats.",
"Set stale-after to at least three heartbeat intervals and at least ten seconds.",
)
def _validate_outbound_connector_policy(
@@ -300,8 +616,19 @@ def _validate_outbound_connector_policy(
runtime: _RuntimeProfile,
collector: _ConfigIssueCollector,
) -> None:
private_networks = _clean(env.get("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS")).lower()
if runtime.production_like and private_networks not in {"true", "false", "1", "0", "yes", "no", "on", "off"}:
private_networks = _clean(
env.get("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS")
).lower()
if runtime.production_like and private_networks not in {
"true",
"false",
"1",
"0",
"yes",
"no",
"on",
"off",
}:
collector.add(
"error",
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
@@ -321,13 +648,21 @@ def _validate_outbound_connector_policy(
except ValueError:
parsed = 0
if parsed <= 0:
collector.add("error", key, f"{key} must be a positive byte count.", "Use a positive integer byte limit.")
collector.add(
"error",
key,
f"{key} must be a positive byte count.",
"Use a positive integer byte limit.",
)
secret_env_names = [
item.strip()
for item in env.get("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST", "").split(",")
if item.strip()
]
if any(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", item) is None for item in secret_env_names):
if any(
re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", item) is None
for item in secret_env_names
):
collector.add(
"error",
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
@@ -348,14 +683,28 @@ def _validate_outbound_connector_policy(
)
def _validate_module_catalog_trust(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG"))
def _validate_module_catalog_trust(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(
env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG")
)
if not runtime.production or not catalog_source:
return
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE")):
collector.add("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "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.")
collector.add(
"error",
"GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE",
"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")):
collector.add("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL", "A module catalog source is configured without an approved release channel.", "Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.")
collector.add(
"error",
"GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL",
"A module catalog source is configured without an approved release channel.",
"Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.",
)
def _self_hosted_env_template(master_key: str) -> str:
@@ -364,6 +713,13 @@ def _self_hosted_env_template(master_key: str) -> str:
APP_ENV=production
GOVOPLAN_INSTALL_PROFILE=self-hosted
GOVOPLAN_INSTALLATION_ID=govoplan-production
GOVOPLAN_STATE_PROFILE=local
GOVOPLAN_RUNTIME_ROLE=api
GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS=15
GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS=60
GOVOPLAN_EXPECTED_API_REPLICAS=1
GOVOPLAN_EXPECTED_WORKER_REPLICAS=1
MASTER_KEY_B64={master_key}
DATABASE_URL=postgresql+psycopg://govoplan:change-me@127.0.0.1:5432/govoplan
@@ -373,7 +729,7 @@ ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,aud
CELERY_ENABLED=true
REDIS_URL=redis://127.0.0.1:6379/0
CELERY_QUEUES=send_email,append_sent,notifications,calendar,dataflow,workflow,events,default
CELERY_QUEUES=send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
@@ -409,6 +765,7 @@ FILE_STORAGE_BACKEND=local
FILE_STORAGE_LOCAL_ROOT=/var/lib/govoplan/files
FILE_STORAGE_LOCAL_FALLBACK_ROOTS=
FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false
FILE_STORAGE_S3_ENDPOINT_TRUSTED=false
FILE_ARCHIVE_MAX_ENTRIES=10000
FILE_ARCHIVE_MAX_EXPANDED_BYTES=2147483648
FILE_ARCHIVE_MAX_EXPANSION_RATIO=100
@@ -430,6 +787,13 @@ def _production_like_env_template(master_key: str) -> str:
APP_ENV=staging
GOVOPLAN_INSTALL_PROFILE=production-like
GOVOPLAN_INSTALLATION_ID=govoplan-production-like
GOVOPLAN_STATE_PROFILE=local
GOVOPLAN_RUNTIME_ROLE=api
GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS=15
GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS=60
GOVOPLAN_EXPECTED_API_REPLICAS=1
GOVOPLAN_EXPECTED_WORKER_REPLICAS=1
MASTER_KEY_B64={master_key}
GOVOPLAN_PRODUCTION_LIKE_POSTGRES_DB=govoplan
@@ -446,7 +810,7 @@ DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
REDIS_URL=redis://127.0.0.1:56379/0
CELERY_ENABLED=true
CELERY_QUEUES=send_email,append_sent,notifications,calendar,dataflow,workflow,events,default
CELERY_QUEUES=send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
@@ -474,6 +838,7 @@ AUTH_COOKIE_SECURE=false
FILE_STORAGE_BACKEND=local
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false
FILE_STORAGE_S3_ENDPOINT_TRUSTED=false
FILE_ARCHIVE_MAX_ENTRIES=10000
FILE_ARCHIVE_MAX_EXPANDED_BYTES=2147483648
FILE_ARCHIVE_MAX_EXPANSION_RATIO=100
@@ -488,7 +853,11 @@ def _clean(value: str | None) -> str:
def _csv(value: str | None) -> tuple[str, ...]:
return tuple(dict.fromkeys(item.strip() for item in str(value or "").split(",") if item.strip()))
return tuple(
dict.fromkeys(
item.strip() for item in str(value or "").split(",") if item.strip()
)
)
def _truthy(value: str | None) -> bool:
File diff suppressed because it is too large Load Diff
+202 -22
View File
@@ -1,15 +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
@@ -23,11 +40,74 @@ 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
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Module is disabled: {module_id}")
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
@@ -96,28 +176,110 @@ 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
if migrate:
self._migrate(plan.enabled_modules)
with get_database().session() as recovery_session:
recovery = begin_runtime_graph_recovery(
recovery_session,
previous_modules=previous,
requested_modules=plan.enabled_modules,
migrate=migrate,
)
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()
}
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,
)
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:
hook(self.context)
if migrate:
self._migrate(plan.enabled_modules)
self.registry.replace(self.available_modules[module_id] for module_id in plan.enabled_modules)
self.configure_runtime()
mounted = tuple(module_id for module_id in plan.enabled_modules if self._mount_module_router(module_id))
for module_id in activated:
hook = self.available_modules[module_id].on_activate
if hook is not None:
hook(self.context)
for module_id in deactivated:
hook = old_manifests[module_id].on_deactivate
if hook is not None:
hook(self.context)
if self._app is not None:
self._app.openapi_schema = None
self.registry.replace(self.available_modules[module_id] for module_id in plan.enabled_modules)
self.configure_runtime()
for module_id in activated:
hook = self.available_modules[module_id].on_activate
if hook is not None:
hook(self.context)
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,
@@ -127,6 +289,24 @@ class ModuleLifecycleManager:
migrations_applied=migrate,
)
def reconcile_workflow_definitions(self) -> Mapping[str, object]:
if not any(
manifest.workflow_definitions for manifest in self.registry.manifests()
):
return {"skipped": True, "reason": "no_contributions"}
provider = workflow_definition_contribution_provider(self.registry)
if provider is None:
return {"skipped": True, "reason": "provider_unavailable"}
from govoplan_core.db.session import get_database
with get_database().session() as session:
result = provider.reconcile(session)
session.commit()
if self._app is not None:
self._app.state.govoplan_workflow_reconciliation = dict(result)
return dict(result)
def _mount_module_router(self, module_id: str) -> bool:
if module_id in self._mounted_modules:
return False
+75 -1
View File
@@ -2,11 +2,13 @@ from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
CAPABILITY_MAIL_DELIVERY_OUTBOX = "mail.delivery_outbox"
CAPABILITY_MAIL_NOTIFICATION_DELIVERY = "mail.notificationDelivery"
CAPABILITY_MAIL_BOUNCE_PROCESSING = "mail.bounce_processing"
@dataclass(frozen=True, slots=True)
@@ -36,7 +38,6 @@ class NotificationMailDeliveryProvider(Protocol):
) -> Mapping[str, object]:
...
@runtime_checkable
class MailDeliveryOutboxProvider(Protocol):
"""Stable worker boundary for Mail-owned external delivery effects."""
@@ -55,11 +56,66 @@ class MailDeliveryOutboxProvider(Protocol):
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 250,
) -> Mapping[str, object]:
...
@dataclass(frozen=True, slots=True)
class MailBounceObservationRef:
id: str
tenant_id: str
profile_id: str
folder: str
uid: str
original_message_id: str | None
command_id: str | None
recipient: str | None
action: str
status_code: str | None
diagnostic: str | None
permanent: bool
observed_at: datetime
matched: bool
evidence: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class MailBounceProcessingProvider(Protocol):
"""Mail-owned DSN ingestion and durable correlation boundary."""
def process_raw_message(
self,
session: object,
*,
tenant_id: str,
profile_id: str,
folder: str,
uid: str,
raw_message: bytes,
) -> tuple[MailBounceObservationRef, ...]:
...
def scan_due(
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 100,
) -> Mapping[str, object]:
...
def observations_for_commands(
self,
session: object,
*,
tenant_id: str,
command_ids: tuple[str, ...],
) -> Mapping[str, tuple[MailBounceObservationRef, ...]]:
...
def notification_mail_delivery_provider(
registry: object | None,
) -> NotificationMailDeliveryProvider | None:
@@ -76,3 +132,21 @@ def notification_mail_delivery_provider(
"NotificationMailDeliveryProvider"
)
return provider
def mail_bounce_processing_provider(
registry: object | None,
) -> MailBounceProcessingProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_MAIL_BOUNCE_PROCESSING)
):
return None
provider = registry.require_capability(CAPABILITY_MAIL_BOUNCE_PROCESSING)
if not isinstance(provider, MailBounceProcessingProvider):
raise TypeError(
"mail.bounce_processing provider does not implement "
"MailBounceProcessingProvider"
)
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",
]
+338 -12
View File
@@ -27,6 +27,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,
@@ -268,6 +274,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 +304,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:
@@ -335,6 +347,18 @@ def module_install_preflight(
issues.append(ModuleInstallerIssue("warning", "empty_plan", "No planned package changes are present."))
if not maintenance_mode:
issues.append(ModuleInstallerIssue("blocker", "maintenance_required", "Package changes require maintenance mode."))
if os.getenv("GOVOPLAN_STATE_PROFILE", "local").strip().lower() == "shared":
issues.append(
ModuleInstallerIssue(
"blocker",
"immutable_cluster_release_required",
(
"Shared-state deployments cannot mutate packages on one runtime node. "
"Build and roll out one immutable release image across every API, worker, "
"and scheduler node."
),
)
)
activation_candidates = desired_modules_after_package_plan(desired_sequence, plan)
issues.extend(module_manifest_compatibility_issues(available, module_ids=activation_candidates))
@@ -491,6 +515,7 @@ 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)
@@ -508,6 +533,7 @@ def run_module_install_plan(
raise ModuleInstallerError("Install preflight is blocked: " + "; ".join(issue.message for issue in preflight.issues if issue.severity == "blocker"))
state = _prepare_module_install_run(
session=session,
plan=plan,
preflight=preflight,
database_url=database_url,
@@ -538,6 +564,7 @@ def run_module_install_plan(
if failed_error is not None:
return _failed_module_install_run_result(
session=session,
state=state,
plan=plan,
executed=executed,
@@ -557,11 +584,13 @@ def run_module_install_plan(
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,
@@ -593,13 +622,39 @@ def _prepare_module_install_run(
verify_modules=True,
)
record_redactions = _installer_secret_redactions(database_url)
record = _initial_module_install_record(
run_id=run_id,
plan=plan,
preflight=preflight,
commands=commands,
record_redactions=record_redactions,
snapshot=_snapshot_environment(
recovery: ModuleLifecycleRecovery | None = None
if not dry_run:
try:
recovery = begin_module_installer_recovery(
session,
run_id=run_id,
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,
@@ -607,7 +662,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,
@@ -615,6 +695,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(
@@ -625,6 +707,7 @@ def _prepare_module_install_run(
result_commands=_command_displays(commands, redactions=record_redactions),
record_redactions=record_redactions,
record=record,
recovery=recovery,
)
@@ -661,6 +744,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,
@@ -673,14 +790,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
@@ -728,6 +922,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]],
@@ -753,6 +948,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,
@@ -763,6 +959,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",
@@ -771,6 +991,7 @@ def _failed_module_install_run_result(
return_code=1,
error=failed_error,
rollback=rollback.as_dict(),
recovery=state.recovery,
)
@@ -783,6 +1004,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:
@@ -794,14 +1016,50 @@ def _applied_module_install_run_result(
)
save_desired_enabled_modules(session, next_desired)
state.record["desired_enabled_after"] = list(next_desired)
session.commit()
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(
@@ -852,6 +1110,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(),
@@ -926,6 +1185,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
@@ -3252,6 +3532,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
@@ -3272,6 +3577,7 @@ def _rollback_after_supervisor_failure(
return_code=1,
error=reason,
rollback=rollback.as_dict(),
recovery=result.recovery,
)
@@ -3679,10 +3985,24 @@ def _snapshot_sqlite_database(run_dir: Path, database_url: str | None) -> dict[s
source.backup(target)
else:
backup_path.touch()
with closing(sqlite3.connect(str(backup_path))) as candidate:
row = candidate.execute("PRAGMA integrity_check").fetchone()
integrity = str(row[0] if row else "missing result")
if integrity.lower() != "ok":
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,
},
}
@@ -3721,6 +4041,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",
]
@@ -3,6 +3,7 @@ from __future__ import annotations
import base64
import binascii
from collections import defaultdict
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
@@ -16,6 +17,11 @@ 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.provider_governance import (
external_provider_from_mapping,
module_architecture_from_mapping,
module_architecture_issues,
)
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_]*)+$")
@@ -238,7 +244,17 @@ def sign_module_package_catalog(
def record_module_package_catalog_acceptance(validation: dict[str, object]) -> None:
state_path = _configured_sequence_state_path()
_record_catalog_acceptance(
validation,
state_path=_configured_sequence_state_path(),
)
def _record_catalog_acceptance(
validation: dict[str, object],
*,
state_path: Path | None,
) -> None:
if state_path is None or validation.get("valid") is not True:
return
channel = validation.get("channel")
@@ -613,6 +629,72 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
"notes": _optional_str(value, "notes"),
"tags": _string_list(value.get("tags")),
}
raw_architecture = value.get("architecture")
if raw_architecture is not None:
if not isinstance(raw_architecture, Mapping):
raise ValueError(
f"Module package catalog architecture for {module_id!r} must be an object."
)
architecture = module_architecture_from_mapping(raw_architecture)
issues = module_architecture_issues(
architecture,
has_migrations=bool(item["migration_tasks"])
or bool(item["migration_notes"]),
)
if issues:
raise ValueError(
f"Module package catalog architecture for {module_id!r} is invalid: "
+ "; ".join(issues)
)
item["architecture"] = architecture.to_dict()
raw_providers = value.get("external_providers")
if raw_providers is not None:
if not isinstance(raw_providers, list):
raise ValueError(
f"Module package catalog external_providers for {module_id!r} must be a list."
)
providers = []
seen_provider_ids: set[str] = set()
for raw_provider in raw_providers:
if not isinstance(raw_provider, Mapping):
raise ValueError(
f"Module package catalog external provider entries for {module_id!r} must be objects."
)
provider = external_provider_from_mapping(raw_provider)
if provider.module_id != module_id:
raise ValueError(
f"Module package catalog provider {provider.id!r} belongs to "
f"{provider.module_id!r}, not {module_id!r}."
)
if provider.id in seen_provider_ids:
raise ValueError(
f"Module package catalog has duplicate provider {provider.id!r}."
)
seen_provider_ids.add(provider.id)
providers.append(provider)
if providers and "architecture" not in item:
raise ValueError(
f"Module package catalog {module_id!r} declares external providers without architecture metadata."
)
if providers:
architecture_payload = item["architecture"]
if not isinstance(architecture_payload, Mapping):
raise ValueError(
f"Module package catalog {module_id!r} has invalid architecture metadata."
)
architecture_modes = set(
_string_list(architecture_payload.get("supported_authority_modes"))
)
provider_modes = {
mode for provider in providers for mode in provider.authority_modes
}
missing_modes = provider_modes - architecture_modes
if missing_modes:
raise ValueError(
f"Module package catalog {module_id!r} provider modes are missing from architecture metadata: "
+ ", ".join(sorted(missing_modes))
)
item["external_providers"] = [provider.to_dict() for provider in providers]
if not version_range_is_valid(
version_min=item["current_version_min"] if isinstance(item["current_version_min"], str) else None,
version_max_exclusive=item["current_version_max_exclusive"] if isinstance(item["current_version_max_exclusive"], str) else None,
+20
View File
@@ -5,10 +5,16 @@ from dataclasses import dataclass, field
from typing import Any, Literal, Protocol, TYPE_CHECKING
from govoplan_core.core.ownership import OwnershipProviderRegistration
from govoplan_core.core.provider_governance import (
ExternalProviderDeclaration,
ExternalProviderStateProviderRegistration,
ModuleArchitectureDeclaration,
)
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.search import (
SearchProviderRegistration,
SearchSourceProviderRegistration,
@@ -268,6 +274,8 @@ class DocumentationTopic:
i18n_key: str | None = None
translations: Mapping[str, Mapping[str, str]] = 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)
@@ -400,6 +408,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]
@@ -418,6 +427,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
@@ -431,6 +441,16 @@ class ModuleManifest:
capability_documentation: Mapping[str, CapabilityDocumentation] = field(default_factory=dict)
search_providers: tuple["SearchProviderRegistration", ...] = ()
search_sources: tuple["SearchSourceProviderRegistration", ...] = ()
operational_check_providers: tuple[
"OperationalCheckProviderRegistration",
...,
] = ()
architecture: ModuleArchitectureDeclaration | None = None
external_providers: tuple[ExternalProviderDeclaration, ...] = ()
external_provider_state_providers: tuple[
ExternalProviderStateProviderRegistration,
...,
] = ()
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
on_activate: LifecycleHook | None = None
on_deactivate: LifecycleHook | None = None
+8
View File
@@ -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,
+622
View File
@@ -0,0 +1,622 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from heapq import nsmallest
import os
from pathlib import Path
import tempfile
from typing import Any, Iterable, Protocol
from urllib.parse import urlsplit
from govoplan_core.security.outbound_http import (
OutboundHttpError,
response_limit,
validate_unpinned_sdk_http_url,
)
class StorageBackendError(RuntimeError):
"""Base error for the deployment-owned object-storage boundary."""
class StorageObjectMissing(StorageBackendError):
"""Raised when a referenced object no longer exists."""
@dataclass(frozen=True, slots=True)
class StorageObjectInfo:
key: str
size_bytes: int
modified_at: datetime | None = None
@dataclass(frozen=True, slots=True)
class StorageObjectPage:
objects: tuple[StorageObjectInfo, ...]
next_cursor: str | None = None
class StorageBackend(Protocol):
"""Shared byte-object storage used by modules without cross-module imports."""
name: str
def put_bytes(
self,
key: str,
data: bytes,
*,
content_type: str | None = None,
) -> None: ...
def get_bytes(self, key: str) -> bytes: ...
def iter_bytes(
self,
key: str,
*,
chunk_size: int = 1024 * 1024,
) -> Iterable[bytes]: ...
def delete(self, key: str) -> None: ...
def exists(self, key: str) -> bool: ...
def stat(self, key: str) -> StorageObjectInfo: ...
def list_objects(
self,
*,
prefix: str,
after: str | None = None,
limit: int = 500,
) -> StorageObjectPage: ...
@dataclass(slots=True)
class LocalFilesystemStorageBackend:
root: Path
fallback_roots: tuple[Path, ...] = field(default_factory=tuple)
name: str = "local"
def __post_init__(self) -> None:
self.root = self.root.expanduser().resolve()
self.fallback_roots = tuple(
root.expanduser().resolve() for root in self.fallback_roots if root
)
self.root.mkdir(parents=True, exist_ok=True)
def _path_for_root(self, root: Path, key: str) -> Path:
normalized = normalize_storage_key(key)
path = (root / normalized).resolve()
if not path.is_relative_to(root):
raise StorageBackendError("Storage key escapes local storage root")
return path
def _path(self, key: str) -> Path:
return self._path_for_root(self.root, key)
def _readable_path(self, key: str) -> Path:
primary = self._path(key)
if primary.exists() and primary.is_file():
return primary
for root in self.fallback_roots:
candidate = self._path_for_root(root, key)
if candidate.exists() and candidate.is_file():
return candidate
raise StorageObjectMissing("Stored object does not exist")
def put_bytes(
self,
key: str,
data: bytes,
*,
content_type: str | None = None,
) -> None:
del content_type
path = self._path(key)
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(
prefix=f".{path.name}.",
suffix=".tmp",
dir=path.parent,
)
temporary = Path(temporary_name)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "wb") as stream:
descriptor = -1
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
temporary.replace(path)
finally:
if descriptor >= 0:
os.close(descriptor)
temporary.unlink(missing_ok=True)
def get_bytes(self, key: str) -> bytes:
return self._readable_path(key).read_bytes()
def iter_bytes(
self,
key: str,
*,
chunk_size: int = 1024 * 1024,
) -> Iterable[bytes]:
path = self._readable_path(key)
with path.open("rb") as handle:
while True:
chunk = handle.read(chunk_size)
if not chunk:
break
yield chunk
def delete(self, key: str) -> None:
path = self._path(key)
if path.exists() and path.is_file():
path.unlink()
def exists(self, key: str) -> bool:
try:
self._readable_path(key)
except StorageObjectMissing:
return False
return True
def stat(self, key: str) -> StorageObjectInfo:
path = self._readable_path(key)
metadata = path.stat()
return StorageObjectInfo(
key=normalize_storage_key(key),
size_bytes=metadata.st_size,
modified_at=datetime.fromtimestamp(
metadata.st_mtime,
tz=timezone.utc,
),
)
def list_objects(
self,
*,
prefix: str,
after: str | None = None,
limit: int = 500,
) -> StorageObjectPage:
normalized_prefix = normalize_storage_prefix(prefix)
normalized_after = normalize_storage_key(after) if after else None
normalized_limit = max(1, min(int(limit), 5000))
def matching_objects() -> Iterable[StorageObjectInfo]:
for path in _iter_local_files(self.root):
key = path.relative_to(self.root).as_posix()
if not key.startswith(normalized_prefix) or (
normalized_after is not None and key <= normalized_after
):
continue
metadata = path.stat()
yield StorageObjectInfo(
key=key,
size_bytes=metadata.st_size,
modified_at=datetime.fromtimestamp(
metadata.st_mtime,
tz=timezone.utc,
),
)
candidates = nsmallest(
normalized_limit + 1,
matching_objects(),
key=lambda item: item.key,
)
has_more = len(candidates) > normalized_limit
page = tuple(candidates[:normalized_limit])
return StorageObjectPage(
objects=page,
next_cursor=page[-1].key if has_more and page else None,
)
@dataclass(slots=True)
class S3StorageBackend:
bucket: str
endpoint_url: str
region_name: str
access_key_id: str
secret_access_key: str
deployment_managed: bool = False
endpoint_trusted: bool = False
name: str = "s3"
_client: Any = field(default=None, init=False, repr=False)
@property
def client(self):
if self._client is not None:
return self._client
if self.deployment_managed:
endpoint_url = _deployment_managed_garage_endpoint(self.endpoint_url)
elif self.endpoint_trusted:
endpoint_url = _trusted_deployment_endpoint(self.endpoint_url)
else:
try:
endpoint_url = validate_unpinned_sdk_http_url(
self.endpoint_url,
label="Object storage S3 endpoint",
)
except OutboundHttpError as exc:
raise StorageBackendError(str(exc)) from exc
try:
import boto3
from botocore.config import Config
except ModuleNotFoundError as exc:
raise StorageBackendError(
"boto3 is required for the S3 storage backend"
) from exc
options: dict[str, object] = {
"endpoint_url": endpoint_url,
"region_name": self.region_name,
"aws_access_key_id": self.access_key_id,
"aws_secret_access_key": self.secret_access_key,
}
if self.deployment_managed:
options["config"] = Config(s3={"addressing_style": "path"})
self._client = boto3.client("s3", **options)
return self._client
def put_bytes(
self,
key: str,
data: bytes,
*,
content_type: str | None = None,
) -> None:
normalized = normalize_storage_key(key)
max_bytes = response_limit("file")
if len(data) > max_bytes:
raise StorageBackendError(
f"Stored object exceeds the deployment limit of {max_bytes} bytes"
)
kwargs: dict[str, object] = {
"Bucket": self.bucket,
"Key": normalized,
"Body": data,
}
if content_type:
kwargs["ContentType"] = content_type
try:
self.client.put_object(**kwargs)
except Exception as exc: # pragma: no cover - depends on S3 backend
raise StorageBackendError(str(exc)) from exc
def get_bytes(self, key: str) -> bytes:
normalized = normalize_storage_key(key)
try:
obj = self.client.get_object(
Bucket=self.bucket,
Key=normalized,
)
max_bytes = response_limit("file")
body = obj["Body"]
try:
_reject_declared_object_size(obj, max_bytes=max_bytes)
data = body.read(max_bytes + 1)
if len(data) > max_bytes:
raise StorageBackendError(
"Stored object exceeds the deployment limit of "
f"{max_bytes} bytes"
)
return data
finally:
if hasattr(body, "close"):
body.close()
except StorageBackendError:
raise
except Exception as exc: # pragma: no cover - depends on S3 backend
if _s3_missing_error(exc):
raise StorageObjectMissing("Stored object does not exist") from exc
raise StorageBackendError(str(exc)) from exc
def iter_bytes(
self,
key: str,
*,
chunk_size: int = 1024 * 1024,
) -> Iterable[bytes]:
normalized = normalize_storage_key(key)
try:
obj = self.client.get_object(
Bucket=self.bucket,
Key=normalized,
)
max_bytes = response_limit("file")
body = obj["Body"]
try:
_reject_declared_object_size(obj, max_bytes=max_bytes)
total = 0
while True:
chunk = body.read(chunk_size)
if not chunk:
break
total += len(chunk)
if total > max_bytes:
raise StorageBackendError(
"Stored object exceeds the deployment limit of "
f"{max_bytes} bytes"
)
yield chunk
finally:
if hasattr(body, "close"):
body.close()
except StorageBackendError:
raise
except Exception as exc: # pragma: no cover - depends on S3 backend
if _s3_missing_error(exc):
raise StorageObjectMissing("Stored object does not exist") from exc
raise StorageBackendError(str(exc)) from exc
def delete(self, key: str) -> None:
try:
self.client.delete_object(
Bucket=self.bucket,
Key=normalize_storage_key(key),
)
except Exception as exc: # pragma: no cover - depends on S3 backend
raise StorageBackendError(str(exc)) from exc
def exists(self, key: str) -> bool:
try:
self.client.head_object(
Bucket=self.bucket,
Key=normalize_storage_key(key),
)
return True
except Exception as exc:
if _s3_missing_error(exc):
return False
raise StorageBackendError(str(exc)) from exc
def stat(self, key: str) -> StorageObjectInfo:
normalized = normalize_storage_key(key)
try:
response = self.client.head_object(
Bucket=self.bucket,
Key=normalized,
)
except Exception as exc: # pragma: no cover - depends on S3 backend
if _s3_missing_error(exc):
raise StorageObjectMissing("Stored object does not exist") from exc
raise StorageBackendError(str(exc)) from exc
try:
size = int(response.get("ContentLength"))
except (AttributeError, TypeError, ValueError) as exc:
raise StorageBackendError(
"S3 object metadata did not include a valid size"
) from exc
return StorageObjectInfo(
key=normalized,
size_bytes=size,
modified_at=_storage_modified_at(response.get("LastModified")),
)
def list_objects(
self,
*,
prefix: str,
after: str | None = None,
limit: int = 500,
) -> StorageObjectPage:
normalized_prefix = normalize_storage_prefix(prefix)
normalized_limit = max(1, min(int(limit), 1000))
kwargs: dict[str, object] = {
"Bucket": self.bucket,
"Prefix": normalized_prefix,
"MaxKeys": normalized_limit,
}
if after:
kwargs["StartAfter"] = normalize_storage_key(after)
try:
response = self.client.list_objects_v2(**kwargs)
except Exception as exc: # pragma: no cover - depends on S3 backend
raise StorageBackendError(str(exc)) from exc
objects = tuple(
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")
)
has_more = bool(response.get("IsTruncated"))
return StorageObjectPage(
objects=objects,
next_cursor=objects[-1].key if has_more and objects else None,
)
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.
Modules own their metadata and key namespaces. The deployment owns the
storage endpoint and credentials, so modules do not need to depend on the
Files package merely to persist opaque generated bytes.
"""
configured = (
str(getattr(settings, "file_storage_backend", "local") or "local")
.strip()
.lower()
)
if configured in {"local", "filesystem", "fs"}:
raw_fallbacks = str(
getattr(settings, "file_storage_local_fallback_roots", "") or ""
)
return LocalFilesystemStorageBackend(
Path(
str(
getattr(
settings,
"file_storage_local_root",
"runtime/files",
)
)
),
fallback_roots=tuple(
Path(item.strip()) for item in raw_fallbacks.split(",") if item.strip()
),
)
if configured in {"s3", "garage"}:
return S3StorageBackend(
bucket=str(
getattr(settings, "file_storage_s3_bucket", None)
or getattr(settings, "s3_bucket", "files")
),
endpoint_url=str(
getattr(settings, "file_storage_s3_endpoint_url", None)
or getattr(settings, "s3_endpoint_url", "")
),
region_name=str(
getattr(settings, "file_storage_s3_region", None)
or getattr(settings, "s3_region", "")
),
access_key_id=str(
getattr(settings, "file_storage_s3_access_key_id", None)
or getattr(settings, "s3_access_key_id", "")
),
secret_access_key=str(
getattr(
settings,
"file_storage_s3_secret_access_key",
None,
)
or getattr(settings, "s3_secret_access_key", "")
),
deployment_managed=bool(
getattr(
settings,
"file_storage_s3_deployment_managed",
False,
)
),
endpoint_trusted=bool(
getattr(
settings,
"file_storage_s3_endpoint_trusted",
False,
)
),
)
raise StorageBackendError(f"Unsupported object storage backend: {configured}")
def normalize_storage_key(value: str) -> str:
candidate = str(value or "").strip().replace("\\", "/")
parts = candidate.split("/")
if (
not candidate
or candidate.startswith("/")
or any(part in {"", ".", ".."} for part in parts)
or any(ord(character) < 32 for character in candidate)
):
raise StorageBackendError("Storage key is not a safe relative key")
return "/".join(parts)
def normalize_storage_prefix(value: str) -> str:
candidate = str(value or "").strip().replace("\\", "/")
if not candidate:
return ""
trailing_slash = candidate.endswith("/")
normalized = normalize_storage_key(candidate.rstrip("/"))
return normalized + ("/" if trailing_slash else "")
def _reject_declared_object_size(obj: object, *, max_bytes: int) -> None:
if not isinstance(obj, dict):
return
try:
declared_size = int(obj.get("ContentLength"))
except (TypeError, ValueError):
return
if declared_size > max_bytes:
raise StorageBackendError(
f"Stored object exceeds the deployment limit of {max_bytes} bytes"
)
def _iter_local_files(root: Path):
for entry in sorted(root.iterdir(), key=lambda item: item.name):
if entry.is_symlink():
continue
if entry.is_dir():
yield from _iter_local_files(entry)
elif entry.is_file():
yield entry
def _s3_missing_error(exc: Exception) -> bool:
response = getattr(exc, "response", None)
if not isinstance(response, dict):
return False
error = response.get("Error")
metadata = response.get("ResponseMetadata")
code = str(error.get("Code") if isinstance(error, dict) else "")
status_code = metadata.get("HTTPStatusCode") if isinstance(metadata, dict) else None
return code in {"404", "NoSuchKey", "NotFound"} or status_code == 404
def _deployment_managed_garage_endpoint(value: str) -> str:
endpoint = str(value or "").strip()
if endpoint != "http://garage:3900":
raise StorageBackendError(
"Deployment-managed S3 trust is restricted to http://garage:3900"
)
return endpoint
def _trusted_deployment_endpoint(value: str) -> str:
endpoint = str(value or "").strip()
parsed = urlsplit(endpoint)
try:
parsed.port
except ValueError as exc:
raise StorageBackendError(
"Deployment-trusted S3 endpoint has an invalid port"
) from exc
if (
parsed.scheme.lower() != "https"
or not parsed.hostname
or parsed.username
or parsed.password
or parsed.query
or parsed.fragment
or parsed.path not in {"", "/"}
):
raise StorageBackendError(
"Deployment-trusted S3 endpoint must be an HTTPS origin without "
"credentials, query, fragment, or path"
)
return endpoint.rstrip("/")
__all__ = [
"LocalFilesystemStorageBackend",
"S3StorageBackend",
"StorageBackend",
"StorageBackendError",
"StorageObjectInfo",
"StorageObjectMissing",
"StorageObjectPage",
"configured_storage_backend",
"normalize_storage_key",
"normalize_storage_prefix",
]
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from typing import Literal
OperationalCheckState = Literal["ok", "warning", "error", "inactive"]
@dataclass(frozen=True, slots=True)
class OperationalCheck:
"""A bounded module-owned runtime check exposed through the Ops module."""
id: str
label: str
state: OperationalCheckState
detail: str
readiness_critical: bool = False
metrics: Mapping[str, object] = field(default_factory=dict)
def as_dict(self) -> dict[str, object]:
return {
"id": self.id,
"label": self.label,
"state": self.state,
"detail": self.detail,
"readiness_critical": self.readiness_critical,
"metrics": dict(self.metrics),
}
OperationalCheckProvider = Callable[[], OperationalCheck]
@dataclass(frozen=True, slots=True)
class OperationalCheckProviderRegistration:
"""Register one independently executable operational check."""
module_id: str
check_id: str
provider: OperationalCheckProvider
cache_seconds: int = 60
+4 -2
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
@@ -79,6 +79,7 @@ class OrganizationFunctionRef:
delegable: bool = False
act_in_place_allowed: bool = False
status: OrganizationStatus = "active"
settings: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
@@ -102,6 +103,7 @@ class OrganizationFunctionTypeRef:
delegable: bool = False
act_in_place_allowed: bool = False
status: OrganizationStatus = "active"
settings: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
@@ -0,0 +1,303 @@
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",
"provided_interface",
"public_route",
"search_provider",
"search_source",
"settings_route",
"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,
},
)
)
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",
]
+91
View File
@@ -26,11 +26,25 @@ ViewGovernanceAction = Literal[
"derive",
"workflow_activate",
]
FunctionAssignmentChangeKind = Literal["request", "grant"]
FunctionAssignmentGovernanceAction = Literal[
"submit",
"approve_holder",
"approve_authority",
"accept_recipient",
"request_changes",
"respond",
"reject",
"withdraw",
"recover",
"apply",
]
CAPABILITY_POLICY_PRIVACY_RETENTION = "policy.privacyRetention"
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY = "policy.schedulingParticipantPrivacy"
CAPABILITY_POLICY_DEFINITION_GOVERNANCE = "policy.definitionGovernance"
CAPABILITY_POLICY_VIEW_GOVERNANCE = "policy.viewGovernance"
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE = "policy.functionAssignmentGovernance"
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = (
"system",
@@ -168,6 +182,83 @@ class PolicyDecision:
}
@dataclass(frozen=True, slots=True)
class FunctionAssignmentGovernanceRequest:
tenant_id: str
kind: FunctionAssignmentChangeKind
action: FunctionAssignmentGovernanceAction
function_id: str
actor: PrincipalRef
candidate_identity_id: str
candidate_account_id: str | None = None
current_state: str = "draft"
function_settings: Mapping[str, Any] = field(default_factory=dict)
context: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class FunctionAssignmentGovernanceDecision:
allowed: bool
reason: str | None = None
profile: str = "unavailable"
required_steps: tuple[str, ...] = ()
authority_function_id: str | None = None
evidence_required: bool = False
recipient_acceptance_required: bool = False
separation_of_duties: bool = True
quorum: int = 1
maximum_validity_days: int | None = None
request_expiry_hours: int = 336
source_path: tuple[PolicySourceStep, ...] = ()
requirements: tuple[str, ...] = ()
details: Mapping[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"allowed": self.allowed,
"reason": self.reason,
"profile": self.profile,
"required_steps": list(self.required_steps),
"authority_function_id": self.authority_function_id,
"evidence_required": self.evidence_required,
"recipient_acceptance_required": (self.recipient_acceptance_required),
"separation_of_duties": self.separation_of_duties,
"quorum": self.quorum,
"maximum_validity_days": self.maximum_validity_days,
"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),
}
@runtime_checkable
class FunctionAssignmentGovernancePolicy(Protocol):
def resolve_function_assignment_action(
self,
session: object | None = None,
*,
request: FunctionAssignmentGovernanceRequest,
) -> FunctionAssignmentGovernanceDecision: ...
def function_assignment_governance_policy(
registry: object | None,
) -> FunctionAssignmentGovernancePolicy | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE)
):
return None
capability = registry.capability(CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE)
return (
capability
if isinstance(capability, FunctionAssignmentGovernancePolicy)
else None
)
@dataclass(frozen=True, slots=True)
class DefinitionScopeRef:
scope_type: DefinitionScopeType
@@ -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",
+32
View File
@@ -196,6 +196,32 @@ class PostboxAttachmentRef:
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxWrappedKeyRef:
"""Opaque envelope-key record; key material remains crypto-provider owned."""
recipient_type: str
recipient_id: str
key_epoch: int
wrapped_key_ref: str
algorithm: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxExternalRecipientTokenRef:
"""External grant state without the bearer secret itself."""
token_id: str
state: str
expires_at: datetime | None = None
one_time: bool = False
key_fetched_at: datetime | None = None
revoked_at: datetime | None = None
assurance_profile: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxMessageRef:
id: str
@@ -221,6 +247,8 @@ class PostboxMessageRef:
key_epoch: int = 1
ciphertext_ref: str | None = None
signed_manifest_ref: str | None = None
wrapped_keys: tuple[PostboxWrappedKeyRef, ...] = ()
external_recipient_tokens: tuple[PostboxExternalRecipientTokenRef, ...] = ()
participants: tuple[PostboxParticipantRef, ...] = ()
attachments: tuple[PostboxAttachmentRef, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
@@ -275,6 +303,10 @@ class PostboxDeliveryRequest:
participants: tuple[PostboxParticipantRef, ...] = ()
attachments: tuple[PostboxAttachmentRef, ...] = ()
expires_at: datetime | None = None
ciphertext_ref: str | None = None
signed_manifest_ref: str | None = None
wrapped_keys: tuple[PostboxWrappedKeyRef, ...] = ()
external_recipient_tokens: tuple[PostboxExternalRecipientTokenRef, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
File diff suppressed because it is too large Load Diff
+680
View File
@@ -0,0 +1,680 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import StrEnum
import hashlib
import json
from typing import Any
from uuid import uuid4
from sqlalchemy import (
BigInteger,
DateTime,
ForeignKey,
Index,
Integer,
JSON,
String,
Text,
UniqueConstraint,
select,
)
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Mapped, Session, mapped_column
from govoplan_core.core.runtime_coordination import LeaseClaim, assert_lease_fence
from govoplan_core.db.base import Base, TimestampMixin, utcnow
from govoplan_core.security.redaction import contains_plain_secret
class RecoveryMode(StrEnum):
ATOMIC = "atomic"
COMPENSATION = "compensation"
SNAPSHOT_RESTORE = "snapshot_restore"
FORWARD_RECOVERY = "forward_recovery"
IRREVERSIBLE = "irreversible"
class RecoveryStatus(StrEnum):
PLANNED = "planned"
PREPARED = "prepared"
RUNNING = "running"
SUCCEEDED = "succeeded"
REJECTED = "rejected"
FAILED = "failed"
OUTCOME_UNKNOWN = "outcome_unknown"
RECOVERY_REQUIRED = "recovery_required"
RECOVERING = "recovering"
RECOVERED = "recovered"
MANUAL_INTERVENTION = "manual_intervention"
TERMINAL_RECOVERY_STATUSES = frozenset(
{
RecoveryStatus.SUCCEEDED.value,
RecoveryStatus.REJECTED.value,
RecoveryStatus.FAILED.value,
RecoveryStatus.RECOVERED.value,
RecoveryStatus.MANUAL_INTERVENTION.value,
}
)
_TRANSITIONS: dict[str, frozenset[str]] = {
RecoveryStatus.PLANNED.value: frozenset(
{RecoveryStatus.PREPARED.value, RecoveryStatus.FAILED.value}
),
RecoveryStatus.PREPARED.value: frozenset(
{RecoveryStatus.RUNNING.value, RecoveryStatus.FAILED.value}
),
RecoveryStatus.RUNNING.value: frozenset(
{
RecoveryStatus.SUCCEEDED.value,
RecoveryStatus.REJECTED.value,
RecoveryStatus.FAILED.value,
RecoveryStatus.OUTCOME_UNKNOWN.value,
RecoveryStatus.RECOVERY_REQUIRED.value,
}
),
RecoveryStatus.OUTCOME_UNKNOWN.value: frozenset(
{
RecoveryStatus.SUCCEEDED.value,
RecoveryStatus.RECOVERY_REQUIRED.value,
RecoveryStatus.MANUAL_INTERVENTION.value,
}
),
RecoveryStatus.RECOVERY_REQUIRED.value: frozenset(
{
RecoveryStatus.RECOVERING.value,
RecoveryStatus.MANUAL_INTERVENTION.value,
}
),
RecoveryStatus.RECOVERING.value: frozenset(
{
RecoveryStatus.RECOVERED.value,
RecoveryStatus.MANUAL_INTERVENTION.value,
}
),
}
class RecoveryGuaranteeError(ValueError):
pass
class RecoveryIdempotencyConflict(RecoveryGuaranteeError):
pass
class RecoveryOperation(Base, TimestampMixin):
__tablename__ = "core_recovery_operations"
__table_args__ = (
UniqueConstraint(
"installation_id",
"module_id",
"idempotency_key",
name="uq_core_recovery_operation_idempotency",
),
Index(
"ix_core_recovery_operations_status_updated",
"installation_id",
"status",
"updated_at",
),
Index(
"ix_core_recovery_operations_resource",
"module_id",
"resource_type",
"resource_id",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid4()),
)
installation_id: Mapped[str] = mapped_column(
String(100), nullable=False, index=True
)
module_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
operation_type: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
resource_type: Mapped[str | None] = mapped_column(String(100), index=True)
resource_id: Mapped[str | None] = mapped_column(String(255), index=True)
mode: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
status: Mapped[str] = mapped_column(
String(40),
default=RecoveryStatus.PLANNED.value,
nullable=False,
index=True,
)
idempotency_key: Mapped[str] = mapped_column(String(200), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
plan: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
backup_reference: Mapped[str | None] = mapped_column(String(1000))
approval_reference: Mapped[str | None] = mapped_column(String(1000))
lease_resource_key: Mapped[str | None] = mapped_column(String(255))
holder_node_id: Mapped[str | None] = mapped_column(String(200))
holder_incarnation: Mapped[str | None] = mapped_column(String(36))
fencing_token: Mapped[int | None] = mapped_column(
BigInteger().with_variant(Integer, "sqlite")
)
checkpoint_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
evidence_head_sha256: Mapped[str | None] = mapped_column(String(64))
failure_summary: Mapped[str | None] = mapped_column(Text)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
recovery_started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True)
)
recovered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
metadata_: Mapped[dict[str, Any]] = mapped_column(
"metadata", JSON, default=dict, nullable=False
)
class RecoveryCheckpoint(Base):
__tablename__ = "core_recovery_checkpoints"
__table_args__ = (
UniqueConstraint(
"operation_id",
"sequence",
name="uq_core_recovery_checkpoint_sequence",
),
Index(
"ix_core_recovery_checkpoints_operation_created",
"operation_id",
"created_at",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid4()),
)
operation_id: Mapped[str] = mapped_column(
ForeignKey("core_recovery_operations.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
kind: Mapped[str] = mapped_column(String(80), nullable=False)
summary: Mapped[str] = mapped_column(Text, nullable=False)
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
previous_sha256: Mapped[str | None] = mapped_column(String(64))
checkpoint_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 RecoveryPlan:
mode: RecoveryMode
preconditions: tuple[str, ...] = ()
compensation_steps: tuple[str, ...] = ()
forward_recovery_steps: tuple[str, ...] = ()
verification_steps: tuple[str, ...] = ()
backup_reference: str | None = None
approval_reference: str | None = None
def validate(self) -> None:
if not self.verification_steps:
raise RecoveryGuaranteeError(
"Recovery plans require at least one verification step"
)
if self.mode == RecoveryMode.COMPENSATION and not self.compensation_steps:
raise RecoveryGuaranteeError(
"Compensation recovery requires explicit compensation steps"
)
if self.mode == RecoveryMode.SNAPSHOT_RESTORE and not self.backup_reference:
raise RecoveryGuaranteeError(
"Snapshot restore requires a verified backup reference"
)
if (
self.mode == RecoveryMode.FORWARD_RECOVERY
and not self.forward_recovery_steps
):
raise RecoveryGuaranteeError(
"Forward recovery requires explicit forward-recovery steps"
)
if self.mode == RecoveryMode.IRREVERSIBLE and not self.approval_reference:
raise RecoveryGuaranteeError(
"Irreversible operations require an approval reference"
)
def as_dict(self) -> dict[str, Any]:
return {
"mode": self.mode.value,
"preconditions": list(self.preconditions),
"compensation_steps": list(self.compensation_steps),
"forward_recovery_steps": list(self.forward_recovery_steps),
"verification_steps": list(self.verification_steps),
"backup_reference": self.backup_reference,
"approval_reference": self.approval_reference,
}
def plan_recovery_operation(
session: Session,
*,
installation_id: str,
module_id: str,
operation_type: str,
idempotency_key: str,
request: dict[str, Any],
recovery_plan: RecoveryPlan,
resource_type: str | None = None,
resource_id: str | None = None,
lease_claim: LeaseClaim | None = None,
metadata: dict[str, Any] | None = None,
) -> RecoveryOperation:
recovery_plan.validate()
request_sha256 = _canonical_sha256(request)
existing = session.execute(
select(RecoveryOperation).where(
RecoveryOperation.installation_id == installation_id,
RecoveryOperation.module_id == module_id,
RecoveryOperation.idempotency_key == idempotency_key,
)
).scalar_one_or_none()
if existing is not None:
if existing.request_sha256 != request_sha256:
raise RecoveryIdempotencyConflict(
"Recovery operation idempotency key was reused for another request"
)
return existing
if contains_plain_secret(metadata or {}):
raise RecoveryGuaranteeError(
"Recovery metadata must contain secret references, not plaintext secrets"
)
if lease_claim is not None:
if lease_claim.installation_id != installation_id:
raise RecoveryGuaranteeError(
"Recovery operation and lease belong to different installations"
)
assert_lease_fence(session, lease_claim)
operation = RecoveryOperation(
installation_id=installation_id,
module_id=module_id,
operation_type=operation_type,
resource_type=resource_type,
resource_id=resource_id,
mode=recovery_plan.mode.value,
status=RecoveryStatus.PLANNED.value,
idempotency_key=idempotency_key,
request_sha256=request_sha256,
plan=recovery_plan.as_dict(),
backup_reference=recovery_plan.backup_reference,
approval_reference=recovery_plan.approval_reference,
lease_resource_key=lease_claim.resource_key if lease_claim else None,
holder_node_id=lease_claim.holder_node_id if lease_claim else None,
holder_incarnation=lease_claim.holder_incarnation if lease_claim else None,
fencing_token=lease_claim.fencing_token if lease_claim else None,
metadata_=dict(metadata or {}),
)
try:
with session.begin_nested():
session.add(operation)
session.flush()
except IntegrityError:
existing = session.execute(
select(RecoveryOperation).where(
RecoveryOperation.installation_id == installation_id,
RecoveryOperation.module_id == module_id,
RecoveryOperation.idempotency_key == idempotency_key,
)
).scalar_one_or_none()
if existing is None:
raise
if existing.request_sha256 != request_sha256:
raise RecoveryIdempotencyConflict(
"Recovery operation idempotency key was reused for another request"
)
return existing
record_recovery_checkpoint(
session,
operation,
kind="plan",
summary="Recovery contract recorded before side effects",
evidence={"request_sha256": request_sha256, "plan": recovery_plan.as_dict()},
lease_claim=lease_claim,
)
return operation
def prepare_recovery_operation(
session: Session,
operation: RecoveryOperation,
*,
evidence: dict[str, Any],
lease_claim: LeaseClaim | None = None,
) -> RecoveryOperation:
_verify_operation_fence(session, operation, lease_claim)
if not evidence:
raise RecoveryGuaranteeError(
"Recovery preparation requires durable precondition evidence"
)
return transition_recovery_operation(
session,
operation,
status=RecoveryStatus.PREPARED,
kind="prepared",
summary="Preconditions and recovery material verified",
evidence=evidence,
lease_claim=lease_claim,
)
def start_recovery_operation(
session: Session,
operation: RecoveryOperation,
*,
evidence: dict[str, Any] | None = None,
lease_claim: LeaseClaim | None = None,
now: datetime | None = None,
) -> RecoveryOperation:
_verify_operation_fence(session, operation, lease_claim)
operation.started_at = _as_utc(now or utcnow())
return transition_recovery_operation(
session,
operation,
status=RecoveryStatus.RUNNING,
kind="started",
summary="Guarded operation started",
evidence=evidence or {},
lease_claim=lease_claim,
)
def transition_recovery_operation(
session: Session,
operation: RecoveryOperation,
*,
status: RecoveryStatus,
kind: str,
summary: str,
evidence: dict[str, Any] | None = None,
failure_summary: str | None = None,
lease_claim: LeaseClaim | None = None,
now: datetime | None = None,
) -> RecoveryOperation:
locked = session.execute(
select(RecoveryOperation)
.where(RecoveryOperation.id == operation.id)
.with_for_update()
).scalar_one()
_verify_operation_fence(session, locked, lease_claim)
allowed = _TRANSITIONS.get(locked.status, frozenset())
if status.value not in allowed:
raise RecoveryGuaranteeError(
f"Recovery transition {locked.status!r} -> {status.value!r} is not allowed"
)
_validate_mode_transition(locked, status)
_validate_transition_evidence(
status=status,
evidence=evidence or {},
failure_summary=failure_summary,
)
observed_at = _as_utc(now or utcnow())
locked.status = status.value
locked.revision = int(locked.revision or 0) + 1
if failure_summary is not None:
locked.failure_summary = failure_summary
if status == RecoveryStatus.SUCCEEDED:
locked.completed_at = observed_at
elif status == RecoveryStatus.RECOVERING:
locked.recovery_started_at = observed_at
elif status == RecoveryStatus.RECOVERED:
locked.recovered_at = observed_at
locked.completed_at = observed_at
elif status in {
RecoveryStatus.REJECTED,
RecoveryStatus.FAILED,
RecoveryStatus.MANUAL_INTERVENTION,
}:
locked.completed_at = observed_at
session.add(locked)
record_recovery_checkpoint(
session,
locked,
kind=kind,
summary=summary,
evidence=evidence or {},
lease_claim=lease_claim,
now=observed_at,
)
session.flush()
return locked
def record_recovery_checkpoint(
session: Session,
operation: RecoveryOperation,
*,
kind: str,
summary: str,
evidence: dict[str, Any],
lease_claim: LeaseClaim | None = None,
now: datetime | None = None,
) -> RecoveryCheckpoint:
if contains_plain_secret(evidence):
raise RecoveryGuaranteeError(
"Recovery evidence must contain secret references, not plaintext secrets"
)
locked = session.execute(
select(RecoveryOperation)
.where(RecoveryOperation.id == operation.id)
.with_for_update()
).scalar_one()
_verify_operation_fence(session, locked, lease_claim)
observed_at = _as_utc(now or utcnow())
sequence = int(locked.checkpoint_count or 0) + 1
payload = {
"operation_id": locked.id,
"sequence": sequence,
"status": locked.status,
"kind": kind,
"summary": summary,
"evidence": evidence,
"previous_sha256": locked.evidence_head_sha256,
"created_at": observed_at.isoformat(),
}
checkpoint_hash = _canonical_sha256(payload)
checkpoint = RecoveryCheckpoint(
operation_id=locked.id,
sequence=sequence,
status=locked.status,
kind=kind,
summary=summary,
evidence=dict(evidence),
previous_sha256=locked.evidence_head_sha256,
checkpoint_sha256=checkpoint_hash,
created_at=observed_at,
)
locked.checkpoint_count = sequence
locked.evidence_head_sha256 = checkpoint_hash
session.add(locked)
session.add(checkpoint)
session.flush()
return checkpoint
def verify_recovery_evidence_chain(
session: Session,
operation_id: str,
) -> bool:
operation = session.get(RecoveryOperation, operation_id)
if operation is None:
raise RecoveryGuaranteeError("Recovery operation was not found")
checkpoints = (
session.execute(
select(RecoveryCheckpoint)
.where(RecoveryCheckpoint.operation_id == operation_id)
.order_by(RecoveryCheckpoint.sequence)
)
.scalars()
.all()
)
previous: str | None = None
for expected_sequence, checkpoint in enumerate(checkpoints, start=1):
if (
checkpoint.sequence != expected_sequence
or checkpoint.previous_sha256 != previous
):
return False
payload = {
"operation_id": checkpoint.operation_id,
"sequence": checkpoint.sequence,
"status": checkpoint.status,
"kind": checkpoint.kind,
"summary": checkpoint.summary,
"evidence": checkpoint.evidence,
"previous_sha256": checkpoint.previous_sha256,
"created_at": _as_utc(checkpoint.created_at).isoformat(),
}
if _canonical_sha256(payload) != checkpoint.checkpoint_sha256:
return False
previous = checkpoint.checkpoint_sha256
return (
len(checkpoints) == int(operation.checkpoint_count or 0)
and previous == operation.evidence_head_sha256
)
def operation_recovery_action(mode: RecoveryMode) -> RecoveryStatus:
if mode == RecoveryMode.ATOMIC:
return RecoveryStatus.FAILED
if mode in {
RecoveryMode.COMPENSATION,
RecoveryMode.SNAPSHOT_RESTORE,
RecoveryMode.FORWARD_RECOVERY,
}:
return RecoveryStatus.RECOVERY_REQUIRED
return RecoveryStatus.MANUAL_INTERVENTION
def _validate_mode_transition(
operation: RecoveryOperation,
status: RecoveryStatus,
) -> None:
mode = RecoveryMode(operation.mode)
if (
operation.status == RecoveryStatus.RUNNING.value
and status == RecoveryStatus.FAILED
and mode != RecoveryMode.ATOMIC
):
raise RecoveryGuaranteeError(
"A non-atomic operation cannot be marked failed after it starts; "
"record recovery_required, outcome_unknown, or manual_intervention"
)
if mode == RecoveryMode.ATOMIC and status in {
RecoveryStatus.RECOVERY_REQUIRED,
RecoveryStatus.RECOVERING,
RecoveryStatus.RECOVERED,
}:
raise RecoveryGuaranteeError(
"Atomic operations must roll back in their transaction instead of entering recovery"
)
if mode == RecoveryMode.IRREVERSIBLE and status in {
RecoveryStatus.RECOVERY_REQUIRED,
RecoveryStatus.RECOVERING,
RecoveryStatus.RECOVERED,
}:
raise RecoveryGuaranteeError(
"Irreversible operations cannot claim automated recovery"
)
def _validate_transition_evidence(
*,
status: RecoveryStatus,
evidence: dict[str, Any],
failure_summary: str | None,
) -> None:
if status in {
RecoveryStatus.SUCCEEDED,
RecoveryStatus.REJECTED,
RecoveryStatus.RECOVERED,
}:
checks = evidence.get("checks")
if (
evidence.get("verified") is not True
or not isinstance(
checks,
(dict, list),
)
or not checks
):
raise RecoveryGuaranteeError(
"Verified terminal transitions require verified evidence and check results"
)
if status == RecoveryStatus.MANUAL_INTERVENTION and not failure_summary:
raise RecoveryGuaranteeError(
"Manual intervention requires an operator-facing failure summary"
)
def _verify_operation_fence(
session: Session,
operation: RecoveryOperation,
lease_claim: LeaseClaim | None,
) -> None:
if operation.lease_resource_key is None:
return
if lease_claim is None:
raise RecoveryGuaranteeError(
"This recovery operation requires its distributed lease fence"
)
if (
lease_claim.resource_key != operation.lease_resource_key
or lease_claim.holder_node_id != operation.holder_node_id
or lease_claim.holder_incarnation != operation.holder_incarnation
or lease_claim.fencing_token != operation.fencing_token
):
raise RecoveryGuaranteeError(
"Recovery operation lease does not match its recorded fence"
)
assert_lease_fence(session, lease_claim)
def _canonical_sha256(value: dict[str, Any]) -> str:
encoded = json.dumps(
value,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
default=str,
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
__all__ = [
"RecoveryCheckpoint",
"RecoveryGuaranteeError",
"RecoveryIdempotencyConflict",
"RecoveryMode",
"RecoveryOperation",
"RecoveryPlan",
"RecoveryStatus",
"TERMINAL_RECOVERY_STATUSES",
"operation_recovery_action",
"plan_recovery_operation",
"prepare_recovery_operation",
"record_recovery_checkpoint",
"start_recovery_operation",
"transition_recovery_operation",
"verify_recovery_evidence_chain",
]
+741
View File
@@ -0,0 +1,741 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from govoplan_core.core.recovery import (
RecoveryGuaranteeError,
RecoveryMode,
RecoveryOperation,
RecoveryPlan,
RecoveryStatus,
plan_recovery_operation,
prepare_recovery_operation,
record_recovery_checkpoint,
start_recovery_operation,
transition_recovery_operation,
verify_recovery_evidence_chain,
)
from govoplan_core.core.runtime_coordination import (
LeaseClaim,
RuntimeIdentity,
acquire_lease,
release_lease,
renew_lease,
)
SessionFactory = Callable[[], Session]
class RecoveryOperationBusy(RecoveryGuaranteeError):
pass
class RecoveryOperationStateConflict(RecoveryGuaranteeError):
def __init__(self, operation_id: str, status: str) -> None:
self.operation_id = operation_id
self.status = status
super().__init__(
f"Recovery operation {operation_id} is already {status}; "
"reconcile it before starting another effect"
)
@dataclass(frozen=True, slots=True)
class DurableRecoveryStart:
operation_id: str
status: str
replayed: bool
operation: DurableRecoveryOperation | None
@dataclass(slots=True)
class DurableRecoveryOperation:
"""Append checkpoints in independent, committed transactions.
The caller's business transaction may roll back without erasing evidence
that an object, queue, filesystem, or provider effect already occurred.
"""
session_factory: SessionFactory
operation_id: str
lease_claim: LeaseClaim
lease_ttl_seconds: int
closed: bool = False
def checkpoint(
self,
*,
kind: str,
summary: str,
evidence: dict[str, Any],
) -> None:
with self.session_factory() as session:
operation, claim = self._locked_and_renewed(session)
record_recovery_checkpoint(
session,
operation,
kind=kind,
summary=summary,
evidence=evidence,
lease_claim=claim,
)
self._verify_chain(session)
session.commit()
def succeed(self, *, evidence: dict[str, Any]) -> None:
with self.session_factory() as session:
operation, claim = self._locked_and_renewed(session)
transition_recovery_operation(
session,
operation,
status=RecoveryStatus.SUCCEEDED,
kind="verified-success",
summary="Operation effects and authoritative state were verified",
evidence=evidence,
lease_claim=claim,
)
self._verify_chain(session)
release_lease(session, claim)
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,
*,
failure_summary: str,
failure_evidence: dict[str, Any],
recovery_evidence: dict[str, Any],
) -> None:
with self.session_factory() as session:
operation, claim = self._locked_and_renewed(session)
if operation.status == RecoveryStatus.RUNNING.value:
operation = transition_recovery_operation(
session,
operation,
status=RecoveryStatus.RECOVERY_REQUIRED,
kind="compensation-required",
summary="The started operation requires explicit compensation",
evidence=failure_evidence,
failure_summary=failure_summary,
lease_claim=claim,
)
if operation.status == RecoveryStatus.RECOVERY_REQUIRED.value:
operation = transition_recovery_operation(
session,
operation,
status=RecoveryStatus.RECOVERING,
kind="compensation-started",
summary="Compensation started",
evidence={"failure_summary": failure_summary},
lease_claim=claim,
)
transition_recovery_operation(
session,
operation,
status=RecoveryStatus.RECOVERED,
kind="compensation-verified",
summary="Compensation restored the declared invariant",
evidence=recovery_evidence,
lease_claim=claim,
)
self._verify_chain(session)
release_lease(session, claim)
session.commit()
self.closed = True
def unresolved(
self,
*,
status: RecoveryStatus,
summary: str,
evidence: dict[str, Any],
failure_summary: str,
) -> None:
if status not in {
RecoveryStatus.OUTCOME_UNKNOWN,
RecoveryStatus.RECOVERY_REQUIRED,
}:
raise ValueError("Unresolved operations require an unresolved status")
with self.session_factory() as session:
operation, claim = self._locked_and_renewed(session)
transition_recovery_operation(
session,
operation,
status=status,
kind="unresolved-effect",
summary=summary,
evidence=evidence,
failure_summary=failure_summary,
lease_claim=claim,
)
self._verify_chain(session)
release_lease(session, claim)
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.
This does not alter the operation state. A later recovery claim treats a
stale `running` operation according to its declared recovery mode.
"""
if self.closed:
return
with self.session_factory() as session:
operation, claim = self._locked_and_renewed(session)
record_recovery_checkpoint(
session,
operation,
kind="authority-released",
summary="Execution authority was released without a terminal claim",
evidence={"status": operation.status},
lease_claim=claim,
)
self._verify_chain(session)
release_lease(session, claim)
session.commit()
self.closed = True
def _locked_and_renewed(
self,
session: Session,
) -> tuple[RecoveryOperation, LeaseClaim]:
if self.closed:
raise RecoveryGuaranteeError("Recovery operation handle is closed")
claim = renew_lease(
session,
self.lease_claim,
ttl_seconds=self.lease_ttl_seconds,
)
operation = session.execute(
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(
"Recovery checkpoint chain verification failed"
)
def begin_durable_recovery_operation(
session_factory: SessionFactory,
*,
identity: RuntimeIdentity,
module_id: str,
operation_type: str,
idempotency_key: str,
request: dict[str, Any],
recovery_plan: RecoveryPlan,
precondition_evidence: dict[str, Any],
lease_resource_key: str,
lease_ttl_seconds: int = 300,
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")
with session_factory() as session:
claim = acquire_lease(
session,
installation_id=identity.installation_id,
resource_key=lease_resource_key,
holder_node_id=identity.node_id,
holder_incarnation=identity.incarnation,
ttl_seconds=lease_ttl_seconds,
metadata={
"module_id": module_id,
"operation_type": operation_type,
},
)
if claim is None:
raise RecoveryOperationBusy(
f"Another runtime owns the recovery fence for {lease_resource_key}"
)
existing = session.execute(
select(RecoveryOperation).where(
RecoveryOperation.installation_id == identity.installation_id,
RecoveryOperation.module_id == module_id,
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,
module_id=module_id,
operation_type=operation_type,
idempotency_key=idempotency_key,
request=request,
recovery_plan=recovery_plan,
resource_type=resource_type,
resource_id=resource_id,
lease_claim=claim,
metadata=metadata,
)
if existing is not None:
if operation.status == RecoveryStatus.SUCCEEDED.value:
release_lease(session, claim)
session.commit()
return DurableRecoveryStart(
operation_id=operation.id,
status=operation.status,
replayed=True,
operation=None,
)
session.rollback()
raise RecoveryOperationStateConflict(operation.id, operation.status)
prepare_recovery_operation(
session,
operation,
evidence=precondition_evidence,
lease_claim=claim,
)
start_recovery_operation(
session,
operation,
evidence={"lease_resource_key": lease_resource_key},
lease_claim=claim,
)
if not verify_recovery_evidence_chain(session, operation.id):
raise RecoveryGuaranteeError(
"Recovery checkpoint chain verification failed before side effects"
)
session.commit()
return DurableRecoveryStart(
operation_id=operation.id,
status=operation.status,
replayed=False,
operation=DurableRecoveryOperation(
session_factory=session_factory,
operation_id=operation.id,
lease_claim=claim,
lease_ttl_seconds=lease_ttl_seconds,
),
)
def claim_durable_recovery_operation(
session_factory: SessionFactory,
*,
identity: RuntimeIdentity,
operation_id: str,
lease_ttl_seconds: int = 300,
) -> DurableRecoveryOperation:
with session_factory() as session:
candidate = session.get(RecoveryOperation, operation_id)
if candidate is None:
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,
}:
raise RecoveryOperationStateConflict(candidate.id, candidate.status)
if not candidate.lease_resource_key:
raise RecoveryGuaranteeError(
"Recovery takeover requires an operation-bound lease resource"
)
claim = acquire_lease(
session,
installation_id=identity.installation_id,
resource_key=candidate.lease_resource_key,
holder_node_id=identity.node_id,
holder_incarnation=identity.incarnation,
ttl_seconds=lease_ttl_seconds,
metadata={"recovery_operation_id": candidate.id},
)
if claim is None:
raise RecoveryOperationBusy(
f"Another runtime owns recovery operation {candidate.id}"
)
operation = session.execute(
select(RecoveryOperation)
.where(RecoveryOperation.id == operation_id)
.with_for_update()
).scalar_one()
previous_fence = {
"holder_node_id": operation.holder_node_id,
"holder_incarnation": operation.holder_incarnation,
"fence_number": operation.fencing_token,
}
operation.holder_node_id = claim.holder_node_id
operation.holder_incarnation = claim.holder_incarnation
operation.fencing_token = claim.fencing_token
session.add(operation)
record_recovery_checkpoint(
session,
operation,
kind="fence-takeover",
summary="A new runtime claimed explicit recovery authority",
evidence={
"previous_fence": previous_fence,
"new_fence_number": claim.fencing_token,
},
lease_claim=claim,
)
if operation.status == RecoveryStatus.RUNNING.value:
mode = RecoveryMode(operation.mode)
if mode == RecoveryMode.ATOMIC:
transition_recovery_operation(
session,
operation,
status=RecoveryStatus.FAILED,
kind="stale-atomic-operation",
summary="The stale database-only transaction rolled back",
evidence={"previous_fence": previous_fence},
failure_summary="Execution authority expired before commit",
lease_claim=claim,
)
elif mode in {RecoveryMode.COMPENSATION, RecoveryMode.SNAPSHOT_RESTORE}:
transition_recovery_operation(
session,
operation,
status=RecoveryStatus.RECOVERY_REQUIRED,
kind="stale-effect-requires-recovery",
summary="Execution authority expired after effects may have started",
evidence={"previous_fence": previous_fence},
failure_summary="Execution authority expired during a non-atomic operation",
lease_claim=claim,
)
else:
transition_recovery_operation(
session,
operation,
status=RecoveryStatus.OUTCOME_UNKNOWN,
kind="stale-effect-outcome-unknown",
summary="Execution authority expired after an external effect may have started",
evidence={"previous_fence": previous_fence},
failure_summary="External effect outcome requires reconciliation",
lease_claim=claim,
)
if not verify_recovery_evidence_chain(session, operation.id):
raise RecoveryGuaranteeError("Recovery checkpoint chain verification failed")
if operation.status == RecoveryStatus.FAILED.value:
release_lease(session, claim)
session.commit()
raise RecoveryOperationStateConflict(operation.id, operation.status)
session.commit()
return DurableRecoveryOperation(
session_factory=session_factory,
operation_id=operation.id,
lease_claim=claim,
lease_ttl_seconds=lease_ttl_seconds,
)
__all__ = [
"DurableRecoveryOperation",
"DurableRecoveryStart",
"RecoveryOperationBusy",
"RecoveryOperationStateConflict",
"begin_durable_recovery_operation",
"claim_durable_recovery_operation",
]
+312
View File
@@ -25,10 +25,26 @@ from govoplan_core.core.modules import (
TenantSummaryProvider,
user_workflow_scope_condition_issues,
)
from govoplan_core.core.module_entitlements import (
TenantModuleEntitlementResolver,
TenantModuleUnavailable,
TenantWorkState,
current_tenant_execution_context,
tenant_execution_scope,
)
from govoplan_core.core.ownership import (
OwnershipProviderRegistration,
ResourceOwnershipProvider,
)
from govoplan_core.core.platform_interfaces import (
validate_manifest_interface_declarations,
)
from govoplan_core.core.provider_governance import (
ExternalProviderDeclaration,
ExternalProviderStateProviderRegistration,
ModuleArchitectureDeclaration,
module_architecture_issues,
)
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
from govoplan_core.core.search import (
RegisteredSearchProvider,
@@ -74,8 +90,10 @@ class PlatformRegistry:
self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = defaultdict(list)
self._ownership_providers: dict[str, OwnershipProviderRegistration] = {}
self._capability_factories: dict[str, CapabilityFactory] = {}
self._capability_factory_owners: dict[str, str] = {}
self._capabilities: dict[str, object] = {}
self._capability_context: ModuleContext | None = None
self._tenant_entitlement_resolver = TenantModuleEntitlementResolver(self)
self._search_provider_registrations: list[RegisteredSearchProvider] = []
self._search_providers: dict[str, SearchProvider] = {}
self._search_source_registrations: list[
@@ -133,6 +151,9 @@ class PlatformRegistry:
})
self._ownership_providers = dict(replacement._ownership_providers)
self._capability_factories = dict(replacement._capability_factories)
self._capability_factory_owners = dict(
replacement._capability_factory_owners
)
self._search_provider_registrations = list(
replacement._search_provider_registrations
)
@@ -142,6 +163,7 @@ class PlatformRegistry:
self._capabilities.clear()
self._search_providers.clear()
self._search_sources.clear()
self._tenant_entitlement_resolver.invalidate()
return snapshot
def get(self, module_id: str) -> ModuleManifest | None:
@@ -163,6 +185,33 @@ class PlatformRegistry:
def manifests(self) -> tuple[ModuleManifest, ...]:
return tuple(self._topologically_sorted())
def module_architectures(
self,
) -> tuple[tuple[str, ModuleArchitectureDeclaration], ...]:
return tuple(
(manifest.id, manifest.architecture)
for manifest in self.manifests()
if manifest.architecture is not None
)
def external_provider_declarations(
self,
) -> tuple[ExternalProviderDeclaration, ...]:
return tuple(
declaration
for manifest in self.manifests()
for declaration in manifest.external_providers
)
def external_provider_state_providers(
self,
) -> tuple[ExternalProviderStateProviderRegistration, ...]:
return tuple(
registration
for manifest in self.manifests()
for registration in manifest.external_provider_state_providers
)
def permissions(self) -> tuple[PermissionDefinition, ...]:
return tuple(permission for manifest in self.manifests() for permission in manifest.permissions)
@@ -222,6 +271,23 @@ class PlatformRegistry:
def configure_capability_context(self, context: ModuleContext) -> None:
self._capability_context = context
self._tenant_entitlement_resolver = TenantModuleEntitlementResolver(
self,
ttl_seconds=float(
getattr(
context.settings,
"tenant_module_entitlement_cache_ttl_seconds",
5.0,
)
),
max_entries=int(
getattr(
context.settings,
"tenant_module_entitlement_cache_max_entries",
2048,
)
),
)
self._capabilities.clear()
self._search_providers.clear()
self._search_sources.clear()
@@ -230,11 +296,77 @@ class PlatformRegistry:
if name in self._capability_factories:
raise RegistryError(f"Duplicate capability: {name}")
self._capability_factories[name] = factory
self._capability_factory_owners[name] = module_id
def has_capability(self, name: str) -> bool:
return name in self._capability_factories
def capability_names(self) -> tuple[str, ...]:
return tuple(sorted(self._capability_factories))
def capability_owner(self, name: str) -> str | None:
return self._capability_factory_owners.get(name)
def public_tenant_resolver(self, module_id: str):
manifest = self.get(module_id)
return manifest.public_tenant_resolver if manifest is not None else None
def tenant_entitlement_resolver(self) -> TenantModuleEntitlementResolver:
return self._tenant_entitlement_resolver
def invalidate_tenant_entitlement(self, tenant_id: str | None = None) -> None:
self._tenant_entitlement_resolver.invalidate(tenant_id)
def tenant_capability(
self,
name: str,
session: object,
*,
tenant_id: str,
work_state: TenantWorkState = "interactive",
) -> object | None:
with tenant_execution_scope(
self._tenant_entitlement_resolver,
session,
tenant_id=tenant_id,
work_state=work_state,
):
return self.capability(name)
def require_tenant_capability(
self,
name: str,
session: object,
*,
tenant_id: str,
work_state: TenantWorkState = "interactive",
) -> object:
owner = self.capability_owner(name)
if owner is not None:
self._tenant_entitlement_resolver.require(
session,
tenant_id=tenant_id,
module_id=owner,
work_state=work_state,
)
capability = self.tenant_capability(
name,
session,
tenant_id=tenant_id,
work_state=work_state,
)
if capability is None:
raise RegistryError(f"Required capability is not available: {name}")
return capability
def capability(self, name: str) -> object | None:
execution = current_tenant_execution_context()
owner = self._capability_factory_owners.get(name)
if execution is not None and owner is not None:
try:
execution.require_module(owner)
except TenantModuleUnavailable:
return None
if name in self._capabilities:
return self._capabilities[name]
factory = self._capability_factories.get(name)
@@ -275,6 +407,12 @@ class PlatformRegistry:
return ()
providers: list[tuple[RegisteredSearchProvider, SearchProvider]] = []
for registered in self.search_provider_registrations():
execution = current_tenant_execution_context()
if execution is not None:
try:
execution.require_module(registered.module_id)
except TenantModuleUnavailable:
continue
key = f"{registered.module_id}:{registered.registration.id}"
provider = self._search_providers.get(key)
if provider is None:
@@ -313,6 +451,12 @@ class PlatformRegistry:
tuple[RegisteredSearchSourceProvider, SearchSourceProvider]
] = []
for registered in self.search_source_registrations():
execution = current_tenant_execution_context()
if execution is not None:
try:
execution.require_module(registered.module_id)
except TenantModuleUnavailable:
continue
key = f"{registered.module_id}:{registered.registration.id}"
provider = self._search_sources.get(key)
if provider is None:
@@ -610,17 +754,180 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
_validate_manifest_overlaps(manifest)
_validate_manifest_migration_spec(manifest)
_validate_manifest_frontend(manifest)
try:
validate_manifest_interface_declarations(manifest)
except ValueError as exc:
raise RegistryError(str(exc)) from exc
for item in manifest.nav_items:
_validate_nav_item(manifest.id, item)
for topic in manifest.documentation:
if not version_range_is_valid(
version_min=topic.version_min,
version_max_exclusive=topic.version_max_exclusive,
):
raise RegistryError(
f"Module {manifest.id!r} documentation topic {topic.id!r} "
"declares an empty version range"
)
for issue in user_workflow_scope_condition_issues(topic):
raise RegistryError(
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
)
_validate_documentation_extensions(manifest)
_validate_architecture_declarations(manifest)
_validate_workflow_definition_contributions(manifest)
def _validate_architecture_declarations(manifest: ModuleManifest) -> None:
architecture = manifest.architecture
if architecture is not None:
for issue in module_architecture_issues(
architecture,
has_migrations=manifest.migration_spec is not None,
):
raise RegistryError(
f"Module {manifest.id!r} architecture declaration: {issue}"
)
provider_ids: set[str] = set()
declared_capabilities = {
*manifest.required_capabilities,
*manifest.optional_capabilities,
*manifest.capability_factories,
}
declared_interfaces = {
*(item.name for item in manifest.provides_interfaces),
*(item.name for item in manifest.requires_interfaces),
}
operational_check_ids = {
item.check_id for item in manifest.operational_check_providers
}
documentation_topic_ids = {item.id for item in manifest.documentation}
ownership_resource_types = {
item.resource_type for item in manifest.ownership_providers
}
provider_authority_modes: set[str] = set()
for declaration in manifest.external_providers:
if declaration.module_id != manifest.id:
raise RegistryError(
f"Provider declaration {declaration.id!r} belongs to "
f"{declaration.module_id!r}, not module {manifest.id!r}"
)
if declaration.id in provider_ids:
raise RegistryError(
f"Module {manifest.id!r} declares duplicate external provider "
f"{declaration.id!r}"
)
provider_ids.add(declaration.id)
provider_authority_modes.update(declaration.authority_modes)
_validate_provider_references(
manifest.id,
declaration,
declared_capabilities=declared_capabilities,
declared_interfaces=declared_interfaces,
operational_check_ids=operational_check_ids,
documentation_topic_ids=documentation_topic_ids,
ownership_resource_types=ownership_resource_types,
)
state_provider_ids: set[str] = set()
for registration in manifest.external_provider_state_providers:
if registration.module_id != manifest.id:
raise RegistryError(
f"Provider state registration {registration.provider_id!r} belongs "
f"to {registration.module_id!r}, not module {manifest.id!r}"
)
if registration.provider_id not in provider_ids:
raise RegistryError(
f"Module {manifest.id!r} registers runtime state for undeclared "
f"provider {registration.provider_id!r}"
)
if registration.provider_id in state_provider_ids:
raise RegistryError(
f"Module {manifest.id!r} registers duplicate runtime state for "
f"provider {registration.provider_id!r}"
)
state_provider_ids.add(registration.provider_id)
missing_state_provider_ids = provider_ids - state_provider_ids
if missing_state_provider_ids:
raise RegistryError(
f"Module {manifest.id!r} external providers require sanitized runtime "
"state providers: " + ", ".join(sorted(missing_state_provider_ids))
)
if architecture is None:
if manifest.external_providers or manifest.external_provider_state_providers:
raise RegistryError(
f"Module {manifest.id!r} declares external providers without a "
"module architecture declaration"
)
return
missing_modes = provider_authority_modes - set(
architecture.supported_authority_modes
)
if missing_modes:
raise RegistryError(
f"Module {manifest.id!r} provider authority modes are not declared "
"by its architecture metadata: " + ", ".join(sorted(missing_modes))
)
unknown_target_providers = set(
architecture.target_tested_providers
) - provider_ids
if unknown_target_providers:
raise RegistryError(
f"Module {manifest.id!r} target-tested providers are not declared: "
+ ", ".join(sorted(unknown_target_providers))
)
def _validate_provider_references(
module_id: str,
declaration: ExternalProviderDeclaration,
*,
declared_capabilities: set[str],
declared_interfaces: set[str],
operational_check_ids: set[str],
documentation_topic_ids: set[str],
ownership_resource_types: set[str],
) -> None:
references = (
(
"capabilities",
set(declaration.capability_names),
declared_capabilities,
),
(
"interfaces",
set(declaration.interface_names),
declared_interfaces,
),
(
"operational checks",
set(declaration.operational_check_ids),
operational_check_ids,
),
(
"documentation topics",
set(declaration.documentation_topic_ids),
documentation_topic_ids,
),
(
"ownership resource types",
set(declaration.ownership_resource_types),
ownership_resource_types,
),
)
for label, requested, available in references:
missing = requested - available
if missing:
raise RegistryError(
f"Module {module_id!r} provider {declaration.id!r} references "
f"undeclared {label}: " + ", ".join(sorted(missing))
)
def _validate_workflow_definition_contributions(
manifest: ModuleManifest,
) -> None:
@@ -818,6 +1125,11 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
)
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
if frontend.public_routes and manifest.public_tenant_resolver is None:
raise RegistryError(
f"Module {manifest.id!r} exposes public frontend routes without a "
"public tenant resolver"
)
for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes):
_validate_frontend_route(manifest.id, route.path, route.component)
for route in (*frontend.routes, *frontend.settings_routes):
+311
View File
@@ -0,0 +1,311 @@
"""Provider-neutral contracts for cross-module governed reports."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
REPORT_PROVIDER_CAPABILITY_PREFIX = "reporting.report_provider."
CAPABILITY_POLICY_REPORTING_GOVERNANCE = "policy.reporting_governance"
CAPABILITY_REPORTING_RETENTION = "reporting.retention"
REPORT_PROVIDER_CONTRACT_VERSION = "1.0"
ReportParameterType = Literal[
"string",
"integer",
"number",
"boolean",
"date",
"datetime",
"reference",
]
ReportFieldType = Literal[
"string",
"integer",
"number",
"boolean",
"date",
"datetime",
"object",
"suppressed_count",
]
ReidentificationRisk = Literal["low", "moderate", "high"]
ReportGovernanceAction = Literal["catalogue", "execute", "export"]
@dataclass(frozen=True, slots=True)
class ReportParameterOption:
value: str
label: str
description: str | None = None
def to_dict(self) -> dict[str, object]:
return {
"value": self.value,
"label": self.label,
"description": self.description,
}
@dataclass(frozen=True, slots=True)
class ReportParameterDescriptor:
key: str
label: str
type: ReportParameterType = "string"
required: bool = False
description: str | None = None
options_from_provider: bool = False
def to_dict(self) -> dict[str, object]:
return {
"key": self.key,
"label": self.label,
"type": self.type,
"required": self.required,
"description": self.description,
"options_from_provider": self.options_from_provider,
}
@dataclass(frozen=True, slots=True)
class ReportResultField:
path: str
label: str
type: ReportFieldType
group: str
nullable: bool = False
sensitive: bool = False
def to_dict(self) -> dict[str, object]:
return {
"path": self.path,
"label": self.label,
"type": self.type,
"group": self.group,
"nullable": self.nullable,
"sensitive": self.sensitive,
}
@dataclass(frozen=True, slots=True)
class ReportPrivacyTransform:
id: str
label: str
required: bool = True
def to_dict(self) -> dict[str, object]:
return {"id": self.id, "label": self.label, "required": self.required}
@dataclass(frozen=True, slots=True)
class ReportDescriptor:
provider_id: str
report_id: str
revision: str
title: str
summary: str
parameters: tuple[ReportParameterDescriptor, ...]
result_schema: tuple[ReportResultField, ...]
privacy_transforms: tuple[ReportPrivacyTransform, ...]
purpose_required: bool = True
audience_scope_required: bool = True
retention_class: str = "report_result"
export_formats: tuple[str, ...] = ("json",)
reidentification_risk: ReidentificationRisk = "moderate"
presentation: Mapping[str, object] = field(default_factory=dict)
contract_version: str = REPORT_PROVIDER_CONTRACT_VERSION
def to_dict(self) -> dict[str, object]:
return {
"contract_version": self.contract_version,
"provider_id": self.provider_id,
"report_id": self.report_id,
"revision": self.revision,
"title": self.title,
"summary": self.summary,
"parameters": [item.to_dict() for item in self.parameters],
"result_schema": [item.to_dict() for item in self.result_schema],
"privacy_transforms": [item.to_dict() for item in self.privacy_transforms],
"purpose_required": self.purpose_required,
"audience_scope_required": self.audience_scope_required,
"retention_class": self.retention_class,
"export_formats": list(self.export_formats),
"reidentification_risk": self.reidentification_risk,
"presentation": dict(self.presentation),
}
@dataclass(frozen=True, slots=True)
class ReportProviderRequest:
report_id: str
parameters: Mapping[str, object]
purpose: str
audience_scope: Mapping[str, object]
@dataclass(frozen=True, slots=True)
class ReportProviderResult:
report_id: str
generated_at: datetime
payload: Mapping[str, object]
source_revisions: tuple[Mapping[str, object], ...]
effective_scope: Mapping[str, object]
applied_privacy_transforms: tuple[str, ...]
provenance: Mapping[str, object]
@runtime_checkable
class ReportProvider(Protocol):
provider_id: str
contract_version: str
def list_reports(
self,
session: object,
principal: object,
) -> tuple[ReportDescriptor, ...]: ...
def parameter_options(
self,
session: object,
principal: object,
*,
report_id: str,
parameter_key: str,
query: str,
limit: int,
) -> tuple[ReportParameterOption, ...]: ...
def execute_report(
self,
session: object,
principal: object,
*,
request: ReportProviderRequest,
) -> ReportProviderResult: ...
def authorize_result(
self,
session: object,
principal: object,
*,
report_id: str,
source_revisions: tuple[Mapping[str, object], ...],
effective_scope: Mapping[str, object],
) -> bool: ...
@dataclass(frozen=True, slots=True)
class ReportingGovernanceRequest:
action: ReportGovernanceAction
tenant_id: str
provider_id: str
report_id: str
purpose: str | None
audience_scope: Mapping[str, object]
retention_class: str
export_format: str | None
reidentification_risk: ReidentificationRisk
declared_privacy_transforms: tuple[str, ...]
applied_privacy_transforms: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class ReportingGovernanceDecision:
allowed: bool
reason: str | None
retention_days: int | None
export_formats: tuple[str, ...]
required_privacy_transforms: tuple[str, ...]
provenance: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class ReportingGovernanceProvider(Protocol):
def decide_reporting_action(
self,
session: object,
principal: object,
*,
request: ReportingGovernanceRequest,
) -> ReportingGovernanceDecision: ...
@runtime_checkable
class ReportingRetentionProvider(Protocol):
def apply_retention(
self,
session: object,
*,
dry_run: bool,
now: datetime,
limit: int = 500,
) -> Mapping[str, int]: ...
def report_providers(registry: object | None) -> tuple[tuple[str, ReportProvider], ...]:
if (
registry is None
or not hasattr(registry, "capability_names")
or not hasattr(registry, "capability")
):
return ()
providers: list[tuple[str, ReportProvider]] = []
for capability_name in registry.capability_names():
if not capability_name.startswith(REPORT_PROVIDER_CAPABILITY_PREFIX):
continue
provider_id = capability_name.removeprefix(REPORT_PROVIDER_CAPABILITY_PREFIX)
capability = registry.capability(capability_name)
if not isinstance(capability, ReportProvider):
raise TypeError(f"Invalid report provider capability: {capability_name}")
if capability.provider_id != provider_id:
raise ValueError(
f"Report provider id {capability.provider_id!r} does not match "
f"capability {capability_name!r}"
)
if capability.contract_version != REPORT_PROVIDER_CONTRACT_VERSION:
raise ValueError(
f"Unsupported report provider contract: {capability.contract_version}"
)
providers.append((provider_id, capability))
return tuple(providers)
def reporting_governance_provider(
registry: object | None,
) -> ReportingGovernanceProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_POLICY_REPORTING_GOVERNANCE)
):
return None
capability = registry.capability(CAPABILITY_POLICY_REPORTING_GOVERNANCE)
if not isinstance(capability, ReportingGovernanceProvider):
raise TypeError("Invalid reporting governance provider capability")
return capability
__all__ = [
"CAPABILITY_POLICY_REPORTING_GOVERNANCE",
"CAPABILITY_REPORTING_RETENTION",
"REPORT_PROVIDER_CAPABILITY_PREFIX",
"REPORT_PROVIDER_CONTRACT_VERSION",
"ReportDescriptor",
"ReportParameterDescriptor",
"ReportParameterOption",
"ReportPrivacyTransform",
"ReportProvider",
"ReportProviderRequest",
"ReportProviderResult",
"ReportResultField",
"ReportingGovernanceDecision",
"ReportingGovernanceProvider",
"ReportingGovernanceRequest",
"ReportingRetentionProvider",
"report_providers",
"reporting_governance_provider",
]
@@ -0,0 +1,629 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import StrEnum
import hashlib
import json
import os
import socket
from typing import Any
from uuid import uuid4
from sqlalchemy import BigInteger, DateTime, Index, Integer, JSON, String, UniqueConstraint, select
from sqlalchemy.orm import Mapped, Session, mapped_column
from govoplan_core.db.base import Base, TimestampMixin, utcnow
class RuntimeNodeState(StrEnum):
ACTIVE = "active"
DRAINING = "draining"
STOPPED = "stopped"
class RuntimeCoordinationError(RuntimeError):
pass
class LeaseUnavailable(RuntimeCoordinationError):
pass
class StaleFence(RuntimeCoordinationError):
pass
class RuntimeNode(Base, TimestampMixin):
__tablename__ = "core_runtime_nodes"
__table_args__ = (
UniqueConstraint(
"installation_id",
"node_id",
name="uq_core_runtime_node_installation_node",
),
Index(
"ix_core_runtime_nodes_installation_state_heartbeat",
"installation_id",
"state",
"last_heartbeat_at",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid4()),
)
installation_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
node_id: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
incarnation: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
role: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
software_version: Mapped[str] = mapped_column(String(80), nullable=False)
composition_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
queues: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
state: Mapped[str] = mapped_column(
String(30),
default=RuntimeNodeState.ACTIVE.value,
nullable=False,
index=True,
)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
last_heartbeat_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
drain_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
drain_reason: Mapped[str | None] = mapped_column(String(500))
stopped_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, default=dict, nullable=False)
class DistributedLease(Base, TimestampMixin):
__tablename__ = "core_distributed_leases"
__table_args__ = (
UniqueConstraint(
"installation_id",
"resource_key",
name="uq_core_distributed_lease_resource",
),
Index(
"ix_core_distributed_leases_expiry",
"installation_id",
"expires_at",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid4()),
)
installation_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
resource_key: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
holder_node_id: Mapped[str | None] = mapped_column(String(200), index=True)
holder_incarnation: Mapped[str | None] = mapped_column(String(36), index=True)
fencing_token: Mapped[int] = mapped_column(
BigInteger().with_variant(Integer, "sqlite"),
default=0,
nullable=False,
)
acquired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
renewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, default=dict, nullable=False)
@dataclass(frozen=True, slots=True)
class RuntimeIdentity:
installation_id: str
node_id: str
incarnation: str
role: str
software_version: str
composition_hash: str
queues: tuple[str, ...] = ()
_process_runtime_identity: RuntimeIdentity | None = None
def bind_process_runtime_identity(identity: RuntimeIdentity | None) -> None:
"""Bind the authority identity used by effects in this OS process."""
global _process_runtime_identity
_process_runtime_identity = identity
def process_runtime_identity() -> RuntimeIdentity:
"""Return the process authority or fail before a consequential effect."""
if _process_runtime_identity is None:
raise RuntimeCoordinationError(
"No runtime identity is bound to the current process"
)
return _process_runtime_identity
@dataclass(frozen=True, slots=True)
class LeaseClaim:
installation_id: str
resource_key: str
holder_node_id: str
holder_incarnation: str
fencing_token: int
expires_at: datetime
def runtime_identity(
settings: object,
*,
software_version: str,
module_ids: tuple[str, ...] = (),
role: str | None = None,
node_id: str | None = None,
incarnation: str | None = None,
queues: tuple[str, ...] | None = None,
) -> RuntimeIdentity:
effective_role = str(
role or getattr(settings, "runtime_role", "api") or "api"
).strip().lower()
effective_node_id = str(
node_id
or getattr(settings, "runtime_node_id", None)
or os.getenv("HOSTNAME")
or socket.gethostname()
).strip()
installation_id = str(
getattr(settings, "installation_id", "govoplan-local")
or "govoplan-local"
).strip()
if not installation_id or not effective_node_id or not effective_role:
raise RuntimeCoordinationError(
"Runtime identity requires installation, node, and role identifiers"
)
effective_queues = queues
if effective_queues is None:
effective_queues = tuple(
item.strip()
for item in str(getattr(settings, "celery_queues", "") or "").split(",")
if item.strip()
)
composition_hash = runtime_composition_hash(module_ids)
return RuntimeIdentity(
installation_id=installation_id,
node_id=effective_node_id,
incarnation=incarnation or str(uuid4()),
role=effective_role,
software_version=str(software_version),
composition_hash=composition_hash,
queues=tuple(sorted(set(effective_queues))),
)
def runtime_composition_hash(module_ids: tuple[str, ...]) -> str:
return hashlib.sha256(
json.dumps(sorted(set(module_ids)), separators=(",", ":")).encode("utf-8")
).hexdigest()
def register_runtime_node(
session: Session,
identity: RuntimeIdentity,
*,
metadata: dict[str, Any] | None = None,
now: datetime | None = None,
) -> RuntimeNode:
observed_at = _as_utc(now or utcnow())
_insert_node_placeholder(session, identity, observed_at)
node = _locked_node(session, identity.installation_id, identity.node_id)
if node is None: # pragma: no cover - guarded by insert/upsert
raise RuntimeCoordinationError("Runtime node registration was not persisted")
node.incarnation = identity.incarnation
node.role = identity.role
node.software_version = identity.software_version
node.composition_hash = identity.composition_hash
node.queues = list(identity.queues)
node.state = RuntimeNodeState.ACTIVE.value
node.started_at = observed_at
node.last_heartbeat_at = observed_at
node.drain_requested_at = None
node.drain_reason = None
node.stopped_at = None
node.metadata_ = dict(metadata or {})
session.add(node)
session.flush()
return node
def heartbeat_runtime_node(
session: Session,
identity: RuntimeIdentity,
*,
now: datetime | None = None,
metadata: dict[str, Any] | None = None,
) -> RuntimeNode:
node = _locked_node(session, identity.installation_id, identity.node_id)
if node is None or node.incarnation != identity.incarnation:
raise RuntimeCoordinationError(
"Runtime heartbeat was rejected for a stale node incarnation"
)
if node.state == RuntimeNodeState.STOPPED.value:
raise RuntimeCoordinationError("Stopped runtime node cannot heartbeat")
node.last_heartbeat_at = _as_utc(now or utcnow())
if metadata is not None:
node.metadata_ = dict(metadata)
session.add(node)
session.flush()
return node
def request_runtime_node_drain(
session: Session,
*,
installation_id: str,
node_id: str,
reason: str | None = None,
now: datetime | None = None,
) -> RuntimeNode:
node = _locked_node(session, installation_id, node_id)
if node is None:
raise RuntimeCoordinationError("Runtime node was not found")
if node.state == RuntimeNodeState.STOPPED.value:
raise RuntimeCoordinationError("Stopped runtime node cannot be drained")
node.state = RuntimeNodeState.DRAINING.value
node.drain_requested_at = _as_utc(now or utcnow())
node.drain_reason = str(reason or "operator request")[:500]
session.add(node)
session.flush()
return node
def cancel_runtime_node_drain(
session: Session,
*,
installation_id: str,
node_id: str,
) -> RuntimeNode:
node = _locked_node(session, installation_id, node_id)
if node is None:
raise RuntimeCoordinationError("Runtime node was not found")
if node.state != RuntimeNodeState.DRAINING.value:
return node
node.state = RuntimeNodeState.ACTIVE.value
node.drain_requested_at = None
node.drain_reason = None
session.add(node)
session.flush()
return node
def stop_runtime_node(
session: Session,
identity: RuntimeIdentity,
*,
now: datetime | None = None,
) -> bool:
node = _locked_node(session, identity.installation_id, identity.node_id)
if node is None or node.incarnation != identity.incarnation:
return False
observed_at = _as_utc(now or utcnow())
node.state = RuntimeNodeState.STOPPED.value
node.stopped_at = observed_at
node.last_heartbeat_at = observed_at
session.add(node)
session.flush()
return True
def runtime_node_is_draining(
session: Session,
identity: RuntimeIdentity,
) -> bool:
node = session.execute(
select(RuntimeNode).where(
RuntimeNode.installation_id == identity.installation_id,
RuntimeNode.node_id == identity.node_id,
RuntimeNode.incarnation == identity.incarnation,
)
).scalar_one_or_none()
return node is not None and node.state == RuntimeNodeState.DRAINING.value
def list_runtime_nodes(
session: Session,
*,
installation_id: str,
stale_after_seconds: int,
now: datetime | None = None,
) -> list[dict[str, Any]]:
observed_at = _as_utc(now or utcnow())
stale_before = observed_at - timedelta(seconds=max(1, stale_after_seconds))
nodes = session.execute(
select(RuntimeNode)
.where(RuntimeNode.installation_id == installation_id)
.order_by(RuntimeNode.role, RuntimeNode.node_id)
).scalars().all()
return [
{
"node_id": node.node_id,
"incarnation": node.incarnation,
"role": node.role,
"software_version": node.software_version,
"composition_hash": node.composition_hash,
"queues": list(node.queues or []),
"state": node.state,
"started_at": _iso(node.started_at),
"last_heartbeat_at": _iso(node.last_heartbeat_at),
"drain_requested_at": _iso(node.drain_requested_at),
"drain_reason": node.drain_reason,
"stopped_at": _iso(node.stopped_at),
"stale": (
node.state != RuntimeNodeState.STOPPED.value
and _as_utc(node.last_heartbeat_at) < stale_before
),
"metadata": dict(node.metadata_ or {}),
}
for node in nodes
]
def acquire_lease(
session: Session,
*,
installation_id: str,
resource_key: str,
holder_node_id: str,
holder_incarnation: str,
ttl_seconds: int,
metadata: dict[str, Any] | None = None,
now: datetime | None = None,
) -> LeaseClaim | None:
if ttl_seconds < 1:
raise ValueError("Lease TTL must be at least one second")
observed_at = _as_utc(now or utcnow())
_insert_lease_placeholder(session, installation_id, resource_key, observed_at)
lease = _locked_lease(session, installation_id, resource_key)
if lease is None: # pragma: no cover - guarded by insert/upsert
raise RuntimeCoordinationError("Distributed lease was not persisted")
same_holder = (
lease.holder_node_id == holder_node_id
and lease.holder_incarnation == holder_incarnation
)
expired = _as_utc(lease.expires_at) <= observed_at
if lease.holder_node_id is not None and not same_holder and not expired:
return None
if not same_holder or expired:
lease.fencing_token = int(lease.fencing_token or 0) + 1
lease.acquired_at = observed_at
lease.holder_node_id = holder_node_id
lease.holder_incarnation = holder_incarnation
lease.renewed_at = observed_at
lease.expires_at = observed_at + timedelta(seconds=ttl_seconds)
lease.metadata_ = dict(metadata or {})
session.add(lease)
session.flush()
return _lease_claim(lease)
def renew_lease(
session: Session,
claim: LeaseClaim,
*,
ttl_seconds: int,
now: datetime | None = None,
) -> LeaseClaim:
if ttl_seconds < 1:
raise ValueError("Lease TTL must be at least one second")
observed_at = _as_utc(now or utcnow())
lease = _locked_lease(session, claim.installation_id, claim.resource_key)
_assert_matching_fence(lease, claim, observed_at=observed_at)
lease.renewed_at = observed_at
lease.expires_at = observed_at + timedelta(seconds=ttl_seconds)
session.add(lease)
session.flush()
return _lease_claim(lease)
def release_lease(
session: Session,
claim: LeaseClaim,
*,
now: datetime | None = None,
) -> None:
observed_at = _as_utc(now or utcnow())
lease = _locked_lease(session, claim.installation_id, claim.resource_key)
_assert_matching_fence(lease, claim, observed_at=observed_at)
lease.holder_node_id = None
lease.holder_incarnation = None
lease.renewed_at = observed_at
lease.expires_at = observed_at
session.add(lease)
session.flush()
def assert_lease_fence(
session: Session,
claim: LeaseClaim,
*,
now: datetime | None = None,
) -> None:
lease = _locked_lease(session, claim.installation_id, claim.resource_key)
_assert_matching_fence(
lease,
claim,
observed_at=_as_utc(now or utcnow()),
)
def _insert_node_placeholder(
session: Session,
identity: RuntimeIdentity,
observed_at: datetime,
) -> None:
values = {
"id": str(uuid4()),
"installation_id": identity.installation_id,
"node_id": identity.node_id,
"incarnation": identity.incarnation,
"role": identity.role,
"software_version": identity.software_version,
"composition_hash": identity.composition_hash,
"queues": list(identity.queues),
"state": RuntimeNodeState.ACTIVE.value,
"started_at": observed_at,
"last_heartbeat_at": observed_at,
"metadata": {},
"created_at": observed_at,
"updated_at": observed_at,
}
_insert_do_nothing(
session,
RuntimeNode.__table__,
values,
conflict_columns=("installation_id", "node_id"),
)
def _insert_lease_placeholder(
session: Session,
installation_id: str,
resource_key: str,
observed_at: datetime,
) -> None:
values = {
"id": str(uuid4()),
"installation_id": installation_id,
"resource_key": resource_key,
"fencing_token": 0,
"expires_at": observed_at,
"metadata": {},
"created_at": observed_at,
"updated_at": observed_at,
}
_insert_do_nothing(
session,
DistributedLease.__table__,
values,
conflict_columns=("installation_id", "resource_key"),
)
def _insert_do_nothing(
session: Session,
table: Any,
values: dict[str, Any],
*,
conflict_columns: tuple[str, ...],
) -> None:
dialect = session.get_bind().dialect.name
if dialect == "postgresql":
from sqlalchemy.dialects.postgresql import insert
statement = insert(table).values(**values).on_conflict_do_nothing(
index_elements=list(conflict_columns)
)
elif dialect == "sqlite":
from sqlalchemy.dialects.sqlite import insert
statement = insert(table).values(**values).on_conflict_do_nothing(
index_elements=list(conflict_columns)
)
else: # pragma: no cover - GovOPlaN supports PostgreSQL and dev SQLite
raise RuntimeCoordinationError(
f"Distributed coordination is unsupported on {dialect!r}"
)
session.execute(statement)
session.flush()
def _locked_node(
session: Session,
installation_id: str,
node_id: str,
) -> RuntimeNode | None:
return session.execute(
select(RuntimeNode)
.where(
RuntimeNode.installation_id == installation_id,
RuntimeNode.node_id == node_id,
)
.with_for_update()
).scalar_one_or_none()
def _locked_lease(
session: Session,
installation_id: str,
resource_key: str,
) -> DistributedLease | None:
return session.execute(
select(DistributedLease)
.where(
DistributedLease.installation_id == installation_id,
DistributedLease.resource_key == resource_key,
)
.with_for_update()
).scalar_one_or_none()
def _assert_matching_fence(
lease: DistributedLease | None,
claim: LeaseClaim,
*,
observed_at: datetime,
) -> None:
if (
lease is None
or lease.holder_node_id != claim.holder_node_id
or lease.holder_incarnation != claim.holder_incarnation
or int(lease.fencing_token) != claim.fencing_token
or _as_utc(lease.expires_at) <= observed_at
):
raise StaleFence(
f"Lease fence is stale for resource {claim.resource_key!r}"
)
def _lease_claim(lease: DistributedLease) -> LeaseClaim:
if lease.holder_node_id is None or lease.holder_incarnation is None:
raise LeaseUnavailable("Distributed lease has no active holder")
return LeaseClaim(
installation_id=lease.installation_id,
resource_key=lease.resource_key,
holder_node_id=lease.holder_node_id,
holder_incarnation=lease.holder_incarnation,
fencing_token=int(lease.fencing_token),
expires_at=_as_utc(lease.expires_at),
)
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _iso(value: datetime | None) -> str | None:
return _as_utc(value).isoformat() if value is not None else None
__all__ = [
"DistributedLease",
"LeaseClaim",
"LeaseUnavailable",
"RuntimeCoordinationError",
"RuntimeIdentity",
"RuntimeNode",
"RuntimeNodeState",
"StaleFence",
"acquire_lease",
"assert_lease_fence",
"cancel_runtime_node_drain",
"heartbeat_runtime_node",
"list_runtime_nodes",
"register_runtime_node",
"release_lease",
"renew_lease",
"request_runtime_node_drain",
"runtime_identity",
"runtime_node_is_draining",
"stop_runtime_node",
]
+40
View File
@@ -5,6 +5,7 @@ from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.events import PlatformEvent
from govoplan_core.core.external_references import ExternalObjectReference
from govoplan_core.core.modules import ModuleContext
@@ -379,6 +380,43 @@ class SearchSourceProvider(Protocol):
"""Return an explicit decision for every requested reference key."""
@runtime_checkable
class SearchEventSourceProvider(Protocol):
"""Optional source extension for committed, idempotent index deltas."""
def index_changes_for_event(
self,
session: object,
*,
event: PlatformEvent,
delivery_key: str,
) -> Sequence[SearchIndexChange]:
"""Translate one committed event into authoritative index changes."""
@runtime_checkable
class SearchIndexCoordinator(Protocol):
"""Worker-facing orchestration surface exposed by the Search module."""
def ingest_event(
self,
session: object,
*,
event: PlatformEvent,
delivery_key: str,
) -> Mapping[str, int]:
...
def process_changes(
self,
session: object,
*,
limit: int = 100,
tenant_id: str | None = None,
) -> Mapping[str, int]:
...
SearchProviderFactory = Callable[[ModuleContext], SearchProvider]
SearchSourceProviderFactory = Callable[
[ModuleContext],
@@ -455,8 +493,10 @@ __all__ = [
"SearchBackfillRequest",
"SearchContextKind",
"SearchDocument",
"SearchEventSourceProvider",
"SearchIndexChange",
"SearchIndexChangeKind",
"SearchIndexCoordinator",
"SearchIndexWriter",
"SearchProvider",
"SearchProviderFactory",
+55 -1
View File
@@ -6,11 +6,17 @@ import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
from typing import Literal, Protocol, runtime_checkable
CAPABILITY_CONNECTORS_TABULAR_SOURCES = "connectors.tabularSources"
CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER = "connectors.tabularSnapshotWriter"
DEFAULT_PREVIEW_BYTES = 1_000_000
DEFAULT_PREVIEW_TIMEOUT_MS = 2_000
TabularSourceMode = Literal["live", "cached", "file_backed", "static"]
TabularHealthStatus = Literal["healthy", "warning", "error", "unknown"]
TabularDiagnosticSeverity = Literal["info", "warning", "error"]
class TabularSourceError(ValueError):
@@ -29,6 +35,10 @@ class TabularSourceValidationError(TabularSourceError):
pass
class TabularSourceUnavailableError(TabularSourceError):
pass
def parse_tabular_csv(
csv_text: str,
*,
@@ -131,6 +141,32 @@ class TabularColumn:
nullable: bool = True
@dataclass(frozen=True, slots=True)
class TabularPushdown:
projections: bool = False
pagination: bool = False
filters: tuple[str, ...] = ()
aggregations: tuple[str, ...] = ()
sorting: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class TabularSourceHealth:
status: TabularHealthStatus = "unknown"
code: str = "source.health_unknown"
summary: str = "Source health has not been checked."
checked_at: datetime | None = None
details: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class TabularPreviewDiagnostic:
severity: TabularDiagnosticSeverity
code: str
message: str
details: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class TabularSource:
"""Opaque, policy-filtered source reference exposed to consuming modules."""
@@ -148,6 +184,9 @@ class TabularSource:
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)
@@ -157,6 +196,8 @@ class TabularReadRequest:
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)
@@ -165,6 +206,12 @@ class TabularReadResult:
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, ...] = ()
@dataclass(frozen=True, slots=True)
@@ -247,7 +294,11 @@ def _capability(registry: object | None, name: str) -> object | None:
__all__ = [
"CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER",
"CAPABILITY_CONNECTORS_TABULAR_SOURCES",
"DEFAULT_PREVIEW_BYTES",
"DEFAULT_PREVIEW_TIMEOUT_MS",
"TabularColumn",
"TabularPreviewDiagnostic",
"TabularPushdown",
"TabularReadRequest",
"TabularReadResult",
"TabularSnapshotInput",
@@ -257,6 +308,9 @@ __all__ = [
"TabularSourceError",
"TabularSourceNotFoundError",
"TabularSourceProvider",
"TabularSourceHealth",
"TabularSourceMode",
"TabularSourceUnavailableError",
"TabularSourceValidationError",
"parse_tabular_csv",
"tabular_snapshot_writer",
+236
View File
@@ -0,0 +1,236 @@
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_TEMPLATE_CATALOG = "templates.catalog"
CAPABILITY_TEMPLATE_RENDERER = "templates.renderer"
TemplateType = Literal[
"label",
"label_sheet",
"envelope",
"serial_letter",
"form_letter",
"list_layout",
"email",
"generic",
]
TemplateOutputFormat = Literal["html", "text"]
TemplateRenderMode = Literal["preview", "final"]
class TemplateContractError(ValueError):
"""Stable base error for provider-neutral template operations."""
class TemplateNotFoundError(TemplateContractError):
pass
class TemplateCompatibilityError(TemplateContractError):
pass
class TemplateRenderError(TemplateContractError):
pass
@dataclass(frozen=True, slots=True)
class TemplateFieldRequirement:
path: str
value_type: Literal[
"string",
"integer",
"number",
"boolean",
"date",
"datetime",
"object",
"array",
] = "string"
label: str | None = None
required: bool = True
description: str | None = None
@dataclass(frozen=True, slots=True)
class TemplateOutputProfile:
id: str
label: str
output_format: TemplateOutputFormat
media_type: str
channel: str = "print"
capabilities: tuple[str, ...] = ()
page: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class TemplateRevisionRef:
id: str
template_id: str
revision: int
definition_hash: str
template_type: TemplateType
usages: tuple[str, ...]
locale: str
required_fields: tuple[TemplateFieldRequirement, ...]
output_profiles: tuple[TemplateOutputProfile, ...]
published_at: datetime | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class TemplateRef:
id: str
tenant_id: str
name: str
slug: str
template_type: TemplateType
status: str
current_revision: int
current_revision_id: str
published_revision_id: str | None
description: str | None = None
scope_type: str = "tenant"
scope_id: str | None = None
read_only: bool = False
updated_at: datetime | None = None
revision: TemplateRevisionRef | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class TemplateCompatibility:
compatible: bool
template_id: str
revision_id: str
usage: str | None
output_format: str | None
missing_fields: tuple[str, ...] = ()
incompatible_fields: tuple[str, ...] = ()
diagnostics: tuple[Mapping[str, object], ...] = ()
@dataclass(frozen=True, slots=True)
class TemplateRenderRequest:
template_id: str
revision: int | None = None
usage: str | None = None
locale: str | None = None
output_format: TemplateOutputFormat = "html"
profile_id: str | None = None
parameters: Mapping[str, object] = field(default_factory=dict)
items: tuple[Mapping[str, object], ...] = ()
input_snapshot: Mapping[str, object] = field(default_factory=dict)
mode: TemplateRenderMode = "preview"
idempotency_key: str | None = None
persist_to_files: bool = False
@dataclass(frozen=True, slots=True)
class TemplateArtifactRef:
kind: Literal["managed_file", "bounded_download"]
filename: str
content_type: str
size_bytes: int
sha256: str
file_asset_id: str | None = None
file_version_id: str | None = None
download_path: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class TemplateRenderResult:
render_id: str
template_id: str
revision_id: str
revision: int
template_hash: str
input_hash: str
renderer_version: str
output_format: TemplateOutputFormat
content_type: str
filename: str
item_count: int
page_count: int
output_sha256: str
output_size_bytes: int
diagnostics: tuple[Mapping[str, object], ...] = ()
artifact: TemplateArtifactRef | None = None
generated_at: datetime | None = None
payload: bytes | None = None
@runtime_checkable
class TemplateCatalogProvider(Protocol):
def list_templates(
self,
session: object,
principal: object,
*,
query: str = "",
usage: str | None = None,
template_type: str | None = None,
locale: str | None = None,
limit: int = 100,
) -> Sequence[TemplateRef]: ...
def get_template(
self,
session: object,
principal: object,
*,
template_id: str,
revision: int | None = None,
) -> TemplateRef | None: ...
def check_compatibility(
self,
session: object,
principal: object,
*,
template_id: str,
revision: int | None = None,
usage: str | None = None,
output_format: str | None = None,
available_fields: Mapping[str, str] | Sequence[str] = (),
) -> TemplateCompatibility: ...
@runtime_checkable
class TemplateRendererProvider(Protocol):
def render(
self,
session: object,
principal: object,
*,
request: TemplateRenderRequest,
) -> TemplateRenderResult: ...
__all__ = [
"CAPABILITY_TEMPLATE_CATALOG",
"CAPABILITY_TEMPLATE_RENDERER",
"TemplateArtifactRef",
"TemplateCatalogProvider",
"TemplateCompatibility",
"TemplateCompatibilityError",
"TemplateContractError",
"TemplateFieldRequirement",
"TemplateNotFoundError",
"TemplateOutputFormat",
"TemplateOutputProfile",
"TemplateRef",
"TemplateRenderError",
"TemplateRenderMode",
"TemplateRenderRequest",
"TemplateRenderResult",
"TemplateRendererProvider",
"TemplateRevisionRef",
"TemplateType",
]
+551
View File
@@ -0,0 +1,551 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import UTC, datetime
import json
from typing import Literal, Protocol, runtime_checkable
CAPABILITY_VOTING_BALLOTS = "voting.ballots"
CAPABILITY_VOTING_PROVIDER_PREFIX = "voting.provider."
VOTING_ASSURANCE_RECORDED = "recorded"
VOTING_ASSURANCE_CONFIDENTIAL = "confidential"
VOTING_ASSURANCE_SECRET = "secret"
VOTING_ASSURANCE_EXTERNAL_CERTIFIED = "external_certified"
VOTING_CERTIFICATION_NOT_CERTIFIED = "not_certified"
VOTING_CERTIFICATION_IN_EVALUATION = "in_evaluation"
VOTING_CERTIFICATION_CERTIFIED = "certified"
VOTING_CERTIFICATION_EXPIRED = "expired"
VOTING_CERTIFICATION_REVOKED = "revoked"
VotingProviderCertificationState = Literal[
"not_certified",
"in_evaluation",
"certified",
"expired",
"revoked",
]
class VotingCapabilityError(ValueError):
"""Stable error raised by Voting capability implementations."""
def voting_provider_capability(provider_id: str) -> str:
normalized = str(provider_id or "").strip().lower()
if not normalized or any(
character not in "abcdefghijklmnopqrstuvwxyz0123456789_.-"
for character in normalized
):
raise VotingCapabilityError("Voting provider id is invalid.")
return f"{CAPABILITY_VOTING_PROVIDER_PREFIX}{normalized}"
@dataclass(frozen=True, slots=True)
class VotingProviderAssuranceDeclaration:
"""Pinned assurance and certification claim made by a Voting provider."""
provider_id: str
implementation_ref: str
supported_assurance_profiles: tuple[
Literal["confidential", "secret", "external_certified"], ...
]
certification_state: VotingProviderCertificationState
protocol_ref: str
protocol_version: str
certification_authority: str | None = None
certification_reference: str | None = None
certification_evidence_ref: str | None = None
certification_valid_from: datetime | None = None
certification_valid_until: datetime | None = None
notes: tuple[str, ...] = ()
def __post_init__(self) -> None:
normalized_id = str(self.provider_id or "").strip().lower()
voting_provider_capability(normalized_id)
if normalized_id != self.provider_id:
raise ValueError("Voting provider assurance id must be normalized.")
for field_name in ("implementation_ref", "protocol_ref", "protocol_version"):
if not str(getattr(self, field_name) or "").strip():
raise ValueError(
f"Voting provider assurance {field_name} is required."
)
profiles = tuple(self.supported_assurance_profiles)
allowed_profiles = {
VOTING_ASSURANCE_CONFIDENTIAL,
VOTING_ASSURANCE_SECRET,
VOTING_ASSURANCE_EXTERNAL_CERTIFIED,
}
if (
not profiles
or len(set(profiles)) != len(profiles)
or not set(profiles) <= allowed_profiles
):
raise ValueError(
"Voting provider assurance profiles must be unique supported external profiles."
)
if self.certification_state not in {
VOTING_CERTIFICATION_NOT_CERTIFIED,
VOTING_CERTIFICATION_IN_EVALUATION,
VOTING_CERTIFICATION_CERTIFIED,
VOTING_CERTIFICATION_EXPIRED,
VOTING_CERTIFICATION_REVOKED,
}:
raise ValueError("Voting provider certification state is invalid.")
valid_from = _aware_datetime(
self.certification_valid_from,
field_name="certification_valid_from",
)
valid_until = _aware_datetime(
self.certification_valid_until,
field_name="certification_valid_until",
)
if valid_from and valid_until and valid_until <= valid_from:
raise ValueError(
"Voting provider certification validity must end after it starts."
)
if self.certification_state == VOTING_CERTIFICATION_CERTIFIED:
required = (
self.certification_authority,
self.certification_reference,
self.certification_evidence_ref,
valid_from,
valid_until,
)
if any(value is None or value == "" for value in required):
raise ValueError(
"Certified Voting providers require authority, reference, evidence, and a validity window."
)
if len(self.notes) > 16 or any(not str(item or "").strip() for item in self.notes):
raise ValueError("Voting provider assurance notes must be bounded non-empty text.")
def is_currently_certified(self, *, at: datetime | None = None) -> bool:
if self.certification_state != VOTING_CERTIFICATION_CERTIFIED:
return False
moment = _aware_datetime(at or datetime.now(UTC), field_name="at")
valid_from = _aware_datetime(
self.certification_valid_from,
field_name="certification_valid_from",
)
valid_until = _aware_datetime(
self.certification_valid_until,
field_name="certification_valid_until",
)
return bool(valid_from and valid_until and valid_from <= moment < valid_until)
def to_dict(self) -> dict[str, object]:
return {
"provider_id": self.provider_id,
"implementation_ref": self.implementation_ref,
"supported_assurance_profiles": list(self.supported_assurance_profiles),
"certification_state": self.certification_state,
"protocol_ref": self.protocol_ref,
"protocol_version": self.protocol_version,
"certification_authority": self.certification_authority,
"certification_reference": self.certification_reference,
"certification_evidence_ref": self.certification_evidence_ref,
"certification_valid_from": _datetime_text(
self.certification_valid_from
),
"certification_valid_until": _datetime_text(
self.certification_valid_until
),
"notes": list(self.notes),
}
@dataclass(frozen=True, slots=True)
class VotingOption:
key: str
label: str
description: str | None = None
@dataclass(frozen=True, slots=True)
class VotingElector:
subject_id: str
label: str | None = None
weight: int = 1
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class VotingBallotCreateCommand:
title: str
method: str
assurance_profile: str
options: tuple[VotingOption, ...]
electorate: tuple[VotingElector, ...]
description: str | None = None
context_module: str | None = None
context_resource_type: str | None = None
context_resource_id: str | None = None
quorum_weight: int = 0
threshold_numerator: int = 1
threshold_denominator: int = 2
allow_replacement: bool = True
opens_at: datetime | None = None
closes_at: datetime | None = None
provider_id: str | None = None
provider_ballot_ref: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class VotingCastCommand:
selections: tuple[str, ...]
idempotency_key: str
elector_id: str | None = None
@dataclass(frozen=True, slots=True)
class VotingBallotRef:
id: str
revision: int
state: str
assurance_profile: str
definition_sha256: str | None = None
electorate_sha256: str | None = None
@dataclass(frozen=True, slots=True)
class VotingReceipt:
ballot_id: str
revision: int
receipt_sha256: str
cast_at: datetime
replaced_previous: bool = False
replayed: bool = False
@dataclass(frozen=True, slots=True)
class VotingResult:
ballot_id: str
revision: int
counts: Mapping[str, int]
weighted_counts: Mapping[str, int]
cast_count: int
cast_weight: int
eligible_count: int
eligible_weight: int
quorum_met: bool
threshold_met: bool
winning_options: tuple[str, ...]
result_sha256: str
evidence: tuple[Mapping[str, object], ...] = ()
def __post_init__(self) -> None:
_validate_provider_evidence(self.evidence)
@dataclass(frozen=True, slots=True)
class ExternalVotingFinalizationRequest:
tenant_id: str
ballot_id: str
provider_ballot_ref: str
definition_sha256: str
electorate_sha256: str
options: tuple[VotingOption, ...]
eligible_count: int
eligible_weight: int
requested_at: datetime
idempotency_key: str
@dataclass(frozen=True, slots=True)
class ExternalVotingPreparationRequest:
tenant_id: str
ballot_id: str
requested_provider_ballot_ref: str | None
definition_sha256: str
electorate_sha256: str
assurance_profile: Literal["confidential", "secret", "external_certified"]
method: str
options: tuple[VotingOption, ...]
electorate: tuple[VotingElector, ...]
allow_replacement: bool
quorum_weight: int
threshold_numerator: int
threshold_denominator: int
opens_at: datetime | None
closes_at: datetime | None
requested_at: datetime
idempotency_key: str
@dataclass(frozen=True, slots=True)
class ExternalVotingBallotRef:
provider_id: str
provider_ballot_ref: str
state: Literal["prepared", "open", "closed", "outcome_unknown"]
definition_sha256: str
electorate_sha256: str
evidence: tuple[Mapping[str, object], ...] = ()
def __post_init__(self) -> None:
_validate_provider_evidence(self.evidence)
@dataclass(frozen=True, slots=True)
class ExternalVotingCastRequest:
tenant_id: str
ballot_id: str
provider_ballot_ref: str
definition_sha256: str
electorate_sha256: str
elector_id: str
selections: tuple[str, ...]
allow_replacement: bool
requested_at: datetime
idempotency_key: str
@runtime_checkable
class ExternalVotingProvider(Protocol):
"""Provider boundary for confidential, secret, or certified voting.
Providers return aggregate results and evidence only. Raw ballots and
provider credentials must not cross this boundary.
"""
def assurance_declaration(self) -> VotingProviderAssuranceDeclaration: ...
def finalize_ballot(
self,
session: object,
principal: object,
*,
request: ExternalVotingFinalizationRequest,
) -> VotingResult: ...
@runtime_checkable
class InteractiveExternalVotingProvider(ExternalVotingProvider, Protocol):
"""Provider that owns external ballot preparation, casting, and tallying."""
def prepare_ballot(
self,
session: object,
principal: object,
*,
request: ExternalVotingPreparationRequest,
) -> ExternalVotingBallotRef: ...
def cast_ballot(
self,
session: object,
principal: object,
*,
request: ExternalVotingCastRequest,
) -> VotingReceipt: ...
def ballot_status(
self,
session: object,
principal: object,
*,
tenant_id: str,
provider_ballot_ref: str,
) -> ExternalVotingBallotRef | None: ...
@runtime_checkable
class VotingBallotProvider(Protocol):
def create_ballot(
self,
session: object,
principal: object,
*,
command: VotingBallotCreateCommand,
idempotency_key: str,
) -> VotingBallotRef: ...
def get_ballot(
self,
session: object,
principal: object,
*,
ballot_id: str,
) -> Mapping[str, object] | None: ...
def open_ballot(
self,
session: object,
principal: object,
*,
ballot_id: str,
expected_revision: int,
idempotency_key: str,
) -> VotingBallotRef: ...
def cast_ballot(
self,
session: object,
principal: object,
*,
ballot_id: str,
command: VotingCastCommand,
) -> VotingReceipt: ...
def close_ballot(
self,
session: object,
principal: object,
*,
ballot_id: str,
expected_revision: int,
idempotency_key: str,
) -> VotingResult: ...
def certify_ballot(
self,
session: object,
principal: object,
*,
ballot_id: str,
expected_revision: int,
evidence: Sequence[Mapping[str, object]],
idempotency_key: str,
) -> VotingBallotRef: ...
def require_voting_provider_assurance(
provider: object,
*,
provider_id: str,
assurance_profile: str,
at: datetime | None = None,
) -> VotingProviderAssuranceDeclaration:
"""Validate and return the provider claim required for a frozen ballot."""
if not isinstance(provider, ExternalVotingProvider):
raise VotingCapabilityError("Voting provider does not implement the contract.")
try:
declaration = provider.assurance_declaration()
except (TypeError, ValueError) as exc:
raise VotingCapabilityError(
"Voting provider assurance declaration was rejected."
) from exc
if not isinstance(declaration, VotingProviderAssuranceDeclaration):
raise VotingCapabilityError(
"Voting provider returned an invalid assurance declaration."
)
normalized_provider_id = str(provider_id or "").strip().lower()
if declaration.provider_id != normalized_provider_id:
raise VotingCapabilityError(
"Voting provider assurance declaration does not match the selected provider."
)
if assurance_profile not in declaration.supported_assurance_profiles:
raise VotingCapabilityError(
"Voting provider does not support the selected assurance profile."
)
if (
assurance_profile == VOTING_ASSURANCE_EXTERNAL_CERTIFIED
and not declaration.is_currently_certified(at=at)
):
raise VotingCapabilityError(
"Externally certified Voting requires a currently valid provider certification."
)
return declaration
def _validate_provider_evidence(
evidence: Sequence[Mapping[str, object]],
) -> None:
if len(evidence) > 64:
raise ValueError("Voting provider evidence exceeds the item limit")
try:
encoded = json.dumps(
[dict(item) for item in evidence],
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
allow_nan=False,
).encode("utf-8")
except (TypeError, ValueError) as exc:
raise ValueError("Voting provider evidence must be bounded JSON") from exc
if len(encoded) > 64 * 1024:
raise ValueError("Voting provider evidence exceeds the size limit")
_reject_sensitive_evidence(evidence)
def _reject_sensitive_evidence(value: object) -> None:
sensitive_fragments = (
"access_token",
"authorization",
"credential",
"cookie",
"key_material",
"password",
"passwd",
"plaintext",
"private_key",
"raw_vote",
"refresh_token",
"secret",
"selection",
)
if isinstance(value, Mapping):
for key, nested in value.items():
normalized = str(key).strip().lower().replace("-", "_")
if any(fragment in normalized for fragment in sensitive_fragments):
raise ValueError("Voting provider evidence contains a sensitive field")
_reject_sensitive_evidence(nested)
elif isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
for nested in value:
_reject_sensitive_evidence(nested)
def _aware_datetime(
value: datetime | None,
*,
field_name: str,
) -> datetime | None:
if value is None:
return None
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError(
f"Voting provider assurance {field_name} must be timezone-aware."
)
return value.astimezone(UTC)
def _datetime_text(value: datetime | None) -> str | None:
aware = _aware_datetime(value, field_name="datetime")
return aware.isoformat() if aware is not None else None
__all__ = [
"CAPABILITY_VOTING_BALLOTS",
"CAPABILITY_VOTING_PROVIDER_PREFIX",
"ExternalVotingBallotRef",
"ExternalVotingCastRequest",
"ExternalVotingFinalizationRequest",
"ExternalVotingPreparationRequest",
"ExternalVotingProvider",
"InteractiveExternalVotingProvider",
"VOTING_ASSURANCE_CONFIDENTIAL",
"VOTING_ASSURANCE_EXTERNAL_CERTIFIED",
"VOTING_ASSURANCE_RECORDED",
"VOTING_ASSURANCE_SECRET",
"VOTING_CERTIFICATION_CERTIFIED",
"VOTING_CERTIFICATION_EXPIRED",
"VOTING_CERTIFICATION_IN_EVALUATION",
"VOTING_CERTIFICATION_NOT_CERTIFIED",
"VOTING_CERTIFICATION_REVOKED",
"VotingBallotCreateCommand",
"VotingBallotProvider",
"VotingBallotRef",
"VotingCapabilityError",
"VotingCastCommand",
"VotingElector",
"VotingOption",
"VotingProviderAssuranceDeclaration",
"VotingProviderCertificationState",
"VotingReceipt",
"VotingResult",
"require_voting_provider_assurance",
"voting_provider_capability",
]
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
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.registry import PlatformRegistry
from govoplan_core.core.runtime import configure_runtime
from govoplan_core.server.registry import (
available_module_manifests,
build_platform_registry,
)
def build_worker_platform_registry(settings: object) -> PlatformRegistry:
"""Build the active capability graph used by an out-of-process worker."""
configured_modules = getattr(settings, "enabled_modules", "")
raw_enabled_modules = load_startup_enabled_modules(configured_modules)
candidate_modules = startup_candidate_module_ids(
configured_modules,
raw_enabled_modules,
)
available_modules = available_module_manifests(
enabled_modules=candidate_modules,
ignore_load_errors=True,
)
enabled_modules = load_startup_enabled_modules(
configured_modules,
available=available_modules,
)
registry = build_platform_registry(enabled_modules)
context = ModuleContext(registry=registry, settings=settings)
configure_runtime(context)
registry.configure_capability_context(context)
return registry
__all__ = ["build_worker_platform_registry"]
+132 -18
View File
@@ -7,11 +7,13 @@ import hashlib
import json
from typing import Protocol, runtime_checkable
from govoplan_core.core.events import PlatformEvent
CAPABILITY_WORKFLOW_RUNTIME_WORKER = "workflow.runtimeWorker"
CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS = (
"workflow.definitionContributions"
)
CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS = "workflow.definitionContributions"
CAPABILITY_WORKFLOW_ORCHESTRATION = "workflow.orchestration"
CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER = "workflow.triggerDispatcher"
@dataclass(frozen=True, slots=True)
@@ -45,6 +47,42 @@ class WorkflowDefinitionContribution:
content_hash: str | None = None
@dataclass(frozen=True, slots=True)
class WorkflowStandardStartRequest:
tenant_id: str
origin_module_id: str
definition_key: str
idempotency_key: str
input: Mapping[str, object] = field(default_factory=dict)
actor_id: str | None = None
correlation_id: str | None = None
start_origin: str = "user"
@dataclass(frozen=True, slots=True)
class WorkflowCurrentStepResolution:
action: str
expected_step_id: str | None = None
actor_id: str | None = None
output: Mapping[str, object] = field(default_factory=dict)
evidence: tuple[str, ...] = ()
comment: str | None = None
@dataclass(frozen=True, slots=True)
class WorkflowInstanceRef:
id: str
tenant_id: str
definition_id: str
definition_revision_id: str
definition_revision: int
definition_hash: str
status: str
current_step_id: str | None = None
current_node_id: str | None = None
replayed: bool = False
def workflow_definition_contribution_hash(
contribution: WorkflowDefinitionContribution,
) -> str:
@@ -89,10 +127,31 @@ class WorkflowRuntimeWorker(Protocol):
self,
session: object,
*,
tenant_id: str | None = None,
now: datetime | None = None,
limit: int = 50,
) -> Mapping[str, object]:
...
) -> Mapping[str, object]: ...
@runtime_checkable
class WorkflowTriggerDispatcher(Protocol):
"""Durable schedule/event start and wait-resumption boundary."""
def dispatch_due(
self,
session: object,
*,
tenant_id: str | None = None,
now: datetime | None = None,
limit: int = 50,
) -> Mapping[str, object]: ...
def ingest_event(
self,
session: object,
*,
event: PlatformEvent,
) -> Mapping[str, object]: ...
@runtime_checkable
@@ -102,8 +161,36 @@ class WorkflowDefinitionContributionProvider(Protocol):
session: object,
*,
tenant_ids: Sequence[str] = (),
) -> Mapping[str, object]:
...
) -> Mapping[str, object]: ...
@runtime_checkable
class WorkflowOrchestrationProvider(Protocol):
def start_standard(
self,
session: object,
principal: object,
*,
request: WorkflowStandardStartRequest,
) -> WorkflowInstanceRef: ...
def resolve_current_step(
self,
session: object,
principal: object,
*,
tenant_id: str,
instance_id: str,
resolution: WorkflowCurrentStepResolution,
) -> WorkflowInstanceRef: ...
def get_instance(
self,
session: object,
*,
tenant_id: str,
instance_id: str,
) -> WorkflowInstanceRef: ...
def workflow_runtime_worker(
@@ -116,11 +203,20 @@ def workflow_runtime_worker(
):
return None
capability = registry.capability(CAPABILITY_WORKFLOW_RUNTIME_WORKER)
return (
capability
if isinstance(capability, WorkflowRuntimeWorker)
else None
)
return capability if isinstance(capability, WorkflowRuntimeWorker) else None
def workflow_trigger_dispatcher(
registry: object | None,
) -> WorkflowTriggerDispatcher | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER)
):
return None
capability = registry.capability(CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER)
return capability if isinstance(capability, WorkflowTriggerDispatcher) else None
def workflow_definition_contribution_provider(
@@ -129,14 +225,10 @@ def workflow_definition_contribution_provider(
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(
CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS
)
or not registry.has_capability(CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS)
):
return None
capability = registry.capability(
CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS
)
capability = registry.capability(CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS)
return (
capability
if isinstance(capability, WorkflowDefinitionContributionProvider)
@@ -144,13 +236,35 @@ def workflow_definition_contribution_provider(
)
def workflow_orchestration_provider(
registry: object | None,
) -> WorkflowOrchestrationProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_WORKFLOW_ORCHESTRATION)
):
return None
capability = registry.capability(CAPABILITY_WORKFLOW_ORCHESTRATION)
return capability if isinstance(capability, WorkflowOrchestrationProvider) else None
__all__ = [
"CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS",
"CAPABILITY_WORKFLOW_ORCHESTRATION",
"CAPABILITY_WORKFLOW_RUNTIME_WORKER",
"CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER",
"WorkflowCurrentStepResolution",
"WorkflowDefinitionContribution",
"WorkflowDefinitionContributionProvider",
"WorkflowInstanceRef",
"WorkflowOrchestrationProvider",
"WorkflowRuntimeWorker",
"WorkflowTriggerDispatcher",
"WorkflowStandardStartRequest",
"workflow_definition_contribution_hash",
"workflow_definition_contribution_provider",
"workflow_orchestration_provider",
"workflow_runtime_worker",
"workflow_trigger_dispatcher",
]
+3
View File
@@ -32,6 +32,9 @@ def create_all_tables() -> None:
# model metadata with the shared SQLAlchemy base before create_all runs.
from govoplan_core.admin import models as core_admin_models # noqa: F401
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401
from govoplan_core.core import first_admin as core_first_admin_models # noqa: F401
from govoplan_core.core import recovery as core_recovery_models # noqa: F401
from govoplan_core.core import runtime_coordination as core_runtime_models # noqa: F401
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
from contextlib import contextmanager
import hashlib
import time
from collections.abc import Iterator
from sqlalchemy import create_engine, text
from sqlalchemy.engine import make_url
from sqlalchemy.exc import SQLAlchemyError
class MigrationLockTimeout(RuntimeError):
pass
def migration_lock_key(installation_id: str, migration_track: str) -> int:
payload = f"govoplan\0{installation_id}\0{migration_track}".encode("utf-8")
return int.from_bytes(hashlib.sha256(payload).digest()[:8], "big", signed=True)
@contextmanager
def deployment_migration_lock(
database_url: str,
*,
installation_id: str,
migration_track: str,
timeout_seconds: float = 900.0,
poll_seconds: float = 2.0,
) -> Iterator[None]:
"""Serialize all release migrations before coordination tables are available."""
if timeout_seconds < 0 or poll_seconds <= 0:
raise ValueError("Migration lock timeout must be non-negative and polling positive")
if make_url(database_url).get_backend_name() != "postgresql":
yield
return
key = migration_lock_key(installation_id, migration_track)
engine = create_engine(database_url)
connection = engine.connect()
acquired = False
try:
deadline = time.monotonic() + timeout_seconds
while True:
acquired = bool(
connection.execute(
text("SELECT pg_try_advisory_lock(:key)"),
{"key": key},
).scalar_one()
)
if acquired:
break
if time.monotonic() >= deadline:
raise MigrationLockTimeout(
"Timed out waiting for the deployment-wide database migration lock"
)
time.sleep(min(poll_seconds, max(0.05, deadline - time.monotonic())))
yield
finally:
if acquired:
try:
connection.execute(
text("SELECT pg_advisory_unlock(:key)"),
{"key": key},
)
except SQLAlchemyError:
# Closing the physical connection releases session locks.
pass
connection.close()
engine.dispose()
__all__ = [
"MigrationLockTimeout",
"deployment_migration_lock",
"migration_lock_key",
]
+113
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import ast
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, replace
import json
@@ -18,6 +19,9 @@ from sqlalchemy import create_engine, inspect, text
from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan
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 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.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
@@ -572,9 +576,73 @@ def alembic_config(
config.attributes["enabled_modules"] = tuple(enabled_modules)
if manifest_factories:
config.attributes["manifest_factories"] = tuple(manifest_factories)
validate_unique_migration_revisions(config)
return config
def validate_unique_migration_revisions(config: Config) -> None:
"""Reject duplicate revision IDs before Alembic assembles the shared graph.
Module migrations use separate version directories, but Alembic revision IDs
still occupy one global namespace. Alembic can otherwise resolve a duplicate
to the wrong module and report a misleading ancestor/head overlap.
"""
locations = tuple(
Path(value).resolve()
for value in config.get_main_option("version_locations", "").split(os.pathsep)
if value.strip()
)
owners: dict[str, list[Path]] = {}
for location in locations:
if not location.is_dir():
continue
for path in sorted(location.glob("*.py")):
revision = _literal_migration_revision(path)
if revision:
owners.setdefault(revision, []).append(path)
duplicates = {
revision: paths
for revision, paths in owners.items()
if len(paths) > 1
}
if not duplicates:
return
details = "; ".join(
f"{revision}: {', '.join(str(path) for path in paths)}"
for revision, paths in sorted(duplicates.items())
)
raise ValueError(
"Alembic revision IDs are global across enabled modules; duplicate "
f"revision declarations found: {details}"
)
def _literal_migration_revision(path: Path) -> str | None:
try:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
except (OSError, SyntaxError, UnicodeError):
return None
for statement in tree.body:
value: ast.expr | None = None
if isinstance(statement, ast.Assign) and any(
isinstance(target, ast.Name) and target.id == "revision"
for target in statement.targets
):
value = statement.value
elif (
isinstance(statement, ast.AnnAssign)
and isinstance(statement.target, ast.Name)
and statement.target.id == "revision"
):
value = statement.value
if isinstance(value, ast.Constant) and isinstance(value.value, str):
return value.value.strip() or None
return None
def database_revision(database_url: str | None = None) -> str | None:
url = database_url or settings.database_url
engine = create_engine(url)
@@ -590,6 +658,51 @@ def database_revision(database_url: str | None = None) -> str | None:
engine.dispose()
def database_migration_heads(
database_url: str | None = None,
) -> tuple[str, ...]:
url = database_url or settings.database_url
engine = create_engine(url)
try:
with engine.connect() as connection:
return tuple(
sorted(MigrationContext.configure(connection).get_current_heads())
)
finally:
engine.dispose()
def configured_migration_heads(
database_url: str | None = None,
*,
enabled_modules: tuple[str, ...] | list[str] | None = None,
manifest_factories: tuple[ManifestFactory, ...] = (),
migration_track: str | None = None,
) -> tuple[str, ...]:
config = alembic_config(
database_url=database_url,
enabled_modules=enabled_modules,
manifest_factories=manifest_factories,
migration_track=migration_track,
)
return tuple(sorted(ScriptDirectory.from_config(config).get_heads()))
def database_is_at_configured_heads(
database_url: str | None = None,
*,
enabled_modules: tuple[str, ...] | list[str] | None = None,
manifest_factories: tuple[ManifestFactory, ...] = (),
migration_track: str | None = None,
) -> bool:
return database_migration_heads(database_url) == configured_migration_heads(
database_url,
enabled_modules=enabled_modules,
manifest_factories=manifest_factories,
migration_track=migration_track,
)
def _has_columns(inspector, table_name: str, required: set[str]) -> bool:
try:
actual = {column["name"] for column in inspector.get_columns(table_name)}
+6 -1
View File
@@ -91,7 +91,12 @@ _default_database: DatabaseHandle | None = None
def configure_database(database_url: str, *, engine: Engine | None = None, dispose_previous: bool = False) -> DatabaseHandle:
global _default_database
if engine is None and _default_database is not None and _default_database.database_url == database_url:
if (
engine is None
and not dispose_previous
and _default_database is not None
and _default_database.database_url == database_url
):
return _default_database
previous_database = _default_database
_default_database = DatabaseHandle(database_url, engine=engine)
@@ -69,6 +69,8 @@ TENANT_PERMISSIONS: tuple[PermissionDefinition, ...] = (
PermissionDefinition("admin:settings:write", "Manage tenant settings", "Change tenant defaults and non-policy settings.", "Tenant administration"),
PermissionDefinition("admin:policies:read", "View tenant policies", "Read tenant policy and governance settings.", "Tenant administration"),
PermissionDefinition("admin:policies:write", "Manage tenant policies", "Change tenant policy and governance settings where system policy permits it.", "Tenant administration"),
PermissionDefinition("admin:module:read", "View tenant modules", "Inspect module availability, requirements, and effective state for the active tenant.", "Tenant administration"),
PermissionDefinition("admin:module:write", "Manage tenant modules", "Enable or disable modules for the active tenant within system policy.", "Tenant administration"),
)
SYSTEM_PERMISSIONS: tuple[PermissionDefinition, ...] = (
+2
View File
@@ -8,6 +8,7 @@ from govoplan_core.db.session import configure_database
from govoplan_core.server.config import GovoplanServerConfig, load_server_config
from govoplan_core.server.fastapi import create_govoplan_app
from govoplan_core.server.platform import create_platform_router
from govoplan_core.server.bootstrap import create_bootstrap_router
from govoplan_core.server.credentials import router as credential_router
from govoplan_core.server.ownership import router as ownership_router
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
@@ -69,6 +70,7 @@ def _server_api_router(server_config: GovoplanServerConfig, registry) -> APIRout
for router in server_config.base_routers:
api_router.include_router(router)
api_router.include_router(create_platform_router(settings=server_config.settings))
api_router.include_router(create_bootstrap_router(server_config.settings))
api_router.include_router(credential_router)
api_router.include_router(ownership_router)
for router in server_config.post_module_routers:
+171
View File
@@ -0,0 +1,171 @@
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from pydantic import BaseModel, Field, SecretStr
from sqlalchemy.orm import Session
from govoplan_core.core.access import (
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER,
FirstAdminProvisioner,
)
from govoplan_core.core.first_admin import (
FirstAdminEnrollmentConflict,
FirstAdminEnrollmentCredentialError,
FirstAdminEnrollmentUnavailable,
consume_first_admin_credential,
first_admin_enrollment_status,
)
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.session import get_session
class FirstAdminReadinessResponse(BaseModel):
enrollment_required: bool
credential_active: bool
state: str
generation: int = 0
expires_at: datetime | None = None
readiness: dict[str, bool] = Field(default_factory=dict)
class FirstAdminEnrollmentRequest(BaseModel):
email: str = Field(min_length=3, max_length=320)
display_name: str | None = Field(default=None, max_length=255)
password: SecretStr = Field(min_length=12, max_length=1024)
tenant_slug: str = Field(default="default", min_length=1, max_length=100)
tenant_name: str = Field(default="Default Tenant", min_length=1, max_length=255)
class FirstAdminEnrollmentResponse(BaseModel):
account_id: str
membership_id: str | None = None
tenant_id: str | None = None
email: str
display_name: str | None = None
replayed: bool = False
bootstrap_retired: bool = True
def create_bootstrap_router(settings: object) -> APIRouter:
router = APIRouter(prefix="/bootstrap", tags=["bootstrap"])
@router.get("/status", response_model=FirstAdminReadinessResponse)
def bootstrap_status(
request: Request,
session: Session = Depends(get_session),
) -> FirstAdminReadinessResponse:
provisioner = _first_admin_provisioner(request, required=False)
if provisioner is None:
return FirstAdminReadinessResponse(
enrollment_required=False,
credential_active=False,
state="not_ready",
readiness={
"database": True,
"access_capability": False,
"administrator_absent": False,
},
)
enrollment = first_admin_enrollment_status(
session,
installation_id=str(getattr(settings, "installation_id", "govoplan-local")),
provisioner=provisioner,
)
return FirstAdminReadinessResponse(
enrollment_required=enrollment.enrollment_required,
credential_active=enrollment.credential_active,
state=enrollment.state,
generation=enrollment.generation,
expires_at=enrollment.expires_at,
readiness=enrollment.readiness,
)
@router.post(
"/first-admin",
response_model=FirstAdminEnrollmentResponse,
status_code=status.HTTP_201_CREATED,
)
def enroll_first_admin(
payload: FirstAdminEnrollmentRequest,
request: Request,
x_govoplan_enrollment_token: str = Header(
min_length=32,
max_length=512,
alias="X-GovOPlaN-Enrollment-Token",
),
session: Session = Depends(get_session),
) -> FirstAdminEnrollmentResponse:
provisioner = _first_admin_provisioner(request, required=True)
assert provisioner is not None
try:
result = consume_first_admin_credential(
session,
installation_id=str(getattr(settings, "installation_id", "govoplan-local")),
provisioner=provisioner,
secret=x_govoplan_enrollment_token,
email=payload.email,
display_name=payload.display_name,
password=payload.password.get_secret_value(),
tenant_slug=payload.tenant_slug,
tenant_name=payload.tenant_name,
)
session.commit()
except FirstAdminEnrollmentCredentialError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except FirstAdminEnrollmentUnavailable as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_410_GONE, detail=str(exc)) from exc
except FirstAdminEnrollmentConflict as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
administrator = result.administrator
return FirstAdminEnrollmentResponse(
account_id=administrator.account_id,
membership_id=administrator.membership_id,
tenant_id=administrator.tenant_id,
email=administrator.email,
display_name=administrator.display_name,
replayed=result.replayed,
)
return router
def _first_admin_provisioner(
request: Request,
*,
required: bool,
) -> FirstAdminProvisioner | None:
registry = getattr(request.app.state, "govoplan_registry", None)
if not isinstance(registry, PlatformRegistry):
if required:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="The module registry is not ready.",
)
return None
if not registry.has_capability(CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER):
if required:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Install and enable the Access module before enrolling the first administrator.",
)
return None
capability = registry.require_capability(CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER)
if not isinstance(capability, FirstAdminProvisioner):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="The Access first-administrator capability is invalid.",
)
return capability
__all__ = [
"FirstAdminEnrollmentRequest",
"FirstAdminEnrollmentResponse",
"FirstAdminReadinessResponse",
"create_bootstrap_router",
]
+86 -7
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI
from fastapi import HTTPException, status
from sqlalchemy.engine import make_url
from govoplan_core.auth import ApiPrincipal, require_scope
@@ -10,6 +11,12 @@ from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.bootstrap import bootstrap_dev_data, create_all_tables
from govoplan_core.db.session import get_database
from govoplan_core.server.config import GovoplanServerConfig
from govoplan_core.server.runtime_agent import RuntimeNodeAgent
from govoplan_core.core.runtime_coordination import (
RuntimeIdentity,
bind_process_runtime_identity,
runtime_identity,
)
from govoplan_core.settings import Settings, settings
@@ -35,15 +42,61 @@ async def lifespan(app: FastAPI):
api_key_secret=settings.dev_bootstrap_api_key,
user_password=settings.dev_bootstrap_password,
)
yield
lifecycle = getattr(app.state, "govoplan_lifecycle", None)
if lifecycle is not None and hasattr(
lifecycle,
"reconcile_workflow_definitions",
):
lifecycle.reconcile_workflow_definitions()
registry = getattr(app.state, "govoplan_registry", None)
module_ids = (
tuple(manifest.id for manifest in registry.manifests())
if registry is not None
else ()
)
configured_identity = getattr(
app.state,
"govoplan_runtime_identity",
None,
)
runtime_agent = RuntimeNodeAgent(
settings=settings,
software_version=app.version,
module_ids=module_ids,
metadata={"process": "api"},
identity=configured_identity
if isinstance(configured_identity, RuntimeIdentity)
else None,
)
await runtime_agent.start()
app.state.govoplan_runtime_agent = runtime_agent
try:
yield
finally:
await runtime_agent.stop()
def _cors_origins(value: str) -> list[str]:
return [item.strip() for item in value.split(",") if item.strip()]
def register_health_details(app: FastAPI, registry: PlatformRegistry, config_settings: object | None) -> None:
active_settings = config_settings if isinstance(config_settings, Settings) else settings
def register_health_details(
app: FastAPI, registry: PlatformRegistry, config_settings: object | None
) -> None:
active_settings = (
config_settings if isinstance(config_settings, Settings) else settings
)
module_ids = tuple(manifest.id for manifest in registry.manifests())
if not isinstance(
getattr(app.state, "govoplan_runtime_identity", None),
RuntimeIdentity,
):
app.state.govoplan_runtime_identity = runtime_identity(
active_settings,
software_version=app.version,
module_ids=module_ids,
)
bind_process_runtime_identity(app.state.govoplan_runtime_identity)
@app.get("/health/details")
def health_details(
@@ -57,13 +110,39 @@ def register_health_details(app: FastAPI, registry: PlatformRegistry, config_set
"modules": [manifest.id for manifest in registry.manifests()],
"storage": {
"backend": active_settings.file_storage_backend,
"local_root": active_settings.file_storage_local_root if active_settings.file_storage_backend == "local" else None,
"endpoint": active_settings.file_storage_s3_endpoint_url or active_settings.s3_endpoint_url,
"bucket": active_settings.file_storage_s3_bucket or active_settings.s3_bucket,
"region": active_settings.file_storage_s3_region or active_settings.s3_region,
"local_root": active_settings.file_storage_local_root
if active_settings.file_storage_backend == "local"
else None,
"endpoint": active_settings.file_storage_s3_endpoint_url
or active_settings.s3_endpoint_url,
"bucket": active_settings.file_storage_s3_bucket
or active_settings.s3_bucket,
"region": active_settings.file_storage_s3_region
or active_settings.s3_region,
},
}
@app.get("/health/ready")
def health_ready():
runtime_agent = getattr(app.state, "govoplan_runtime_agent", None)
if runtime_agent is not None and not runtime_agent.coordination_healthy:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
"status": "coordination_unavailable",
"node_id": runtime_agent.identity.node_id,
},
)
if runtime_agent is not None and runtime_agent.draining:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
"status": "draining",
"node_id": runtime_agent.identity.node_id,
},
)
return {"status": "ready"}
def get_server_config() -> GovoplanServerConfig:
return GovoplanServerConfig(
+94 -5
View File
@@ -5,9 +5,17 @@ from sqlalchemy.exc import SQLAlchemyError
from govoplan_core.admin.models import SystemSettings
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.auth import ApiPrincipal, get_api_principal, require_any_scope
from govoplan_core.core.maintenance import saved_maintenance_mode
from govoplan_core.core.module_entitlements import (
module_entitlement_payload,
tenant_module_entitlement_state,
)
from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleManifest, NavItem, PublicFrontendRoute
from govoplan_core.core.platform_interfaces import (
manifest_interface_catalog,
platform_interface_catalog,
)
from govoplan_core.core.registry import PlatformRegistry, manifest_view_surfaces
from govoplan_core.core.views import (
VIEW_SURFACE_CONTRACT_VERSION,
@@ -17,6 +25,7 @@ from govoplan_core.core.views import (
)
from govoplan_core.db.session import get_database
from govoplan_core.i18n import system_i18n_payload
from govoplan_core.tenancy.scope import Tenant
def _registry(request: Request) -> PlatformRegistry:
@@ -26,6 +35,49 @@ def _registry(request: Request) -> PlatformRegistry:
return registry
def _effective_manifest_state(
request: Request,
principal: ApiPrincipal,
) -> tuple[PlatformRegistry, tuple[ModuleManifest, ...], object | None]:
"""Resolve only manifests available in the principal's active context."""
registry = _registry(request)
manifests = tuple(registry.manifests())
entitlement = None
principal_ref = getattr(principal, "principal", None)
tenant_id = getattr(principal_ref, "tenant_id", None)
if tenant_id is not None:
try:
with get_database().session() as session:
tenant = session.get(Tenant, tenant_id)
if tenant is None:
raise HTTPException(
status_code=403,
detail="The active tenant is unavailable.",
)
manifest_map = {manifest.id: manifest for manifest in manifests}
entitlement = tenant_module_entitlement_state(
tenant.settings or {},
manifest_map,
runtime_active_modules=manifest_map,
)
except (RuntimeError, SQLAlchemyError) as exc:
raise HTTPException(
status_code=503,
detail="Tenant module entitlement could not be resolved.",
) from exc
effective_ids = (
set(entitlement.effective_modules)
if entitlement is not None
else {manifest.id for manifest in manifests}
)
return (
registry,
tuple(manifest for manifest in manifests if manifest.id in effective_ids),
entitlement,
)
def _nav_item_payload(item: NavItem, module_id: str | None = None) -> dict[str, object]:
return {
"path": item.path,
@@ -156,9 +208,14 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
@router.get("/modules")
def modules(
request: Request,
_principal: ApiPrincipal = Depends(get_api_principal),
principal: ApiPrincipal = Depends(get_api_principal),
):
registry = _registry(request)
registry, manifests, entitlement = _effective_manifest_state(
request,
principal,
)
principal_ref = getattr(principal, "principal", None)
tenant_id = getattr(principal_ref, "tenant_id", None)
return {
"modules": [
{
@@ -168,14 +225,46 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
"dependencies": list(manifest.dependencies),
"optional_dependencies": list(manifest.optional_dependencies),
"enabled": True,
"architecture": (
manifest.architecture.to_dict()
if manifest.architecture is not None
else None
),
"external_providers": [
declaration.to_dict()
for declaration in manifest.external_providers
],
"runtime_ui_capabilities": _runtime_ui_capabilities(manifest.id, settings, registry),
"interface_catalog": {
key: value
for key, value in manifest_interface_catalog(manifest).items()
if key != "declarations"
},
"nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items],
"frontend": _frontend_payload(manifest),
}
for manifest in registry.manifests()
]
for manifest in manifests
],
"module_entitlement": (
module_entitlement_payload(tenant_id, entitlement)
if tenant_id is not None and entitlement is not None
else None
),
}
@router.get("/interface-catalog")
def interface_catalog(
request: Request,
principal: ApiPrincipal = Depends(
require_any_scope("admin:module:read", "system:settings:read")
),
):
_registry_item, manifests, _entitlement = _effective_manifest_state(
request,
principal,
)
return platform_interface_catalog(manifests)
@router.get("/public-modules")
def public_modules(request: Request):
registry = _registry(request)
+145
View File
@@ -0,0 +1,145 @@
from __future__ import annotations
import asyncio
import logging
from typing import Any
from govoplan_core.core.runtime_coordination import (
RuntimeIdentity,
heartbeat_runtime_node,
register_runtime_node,
runtime_identity,
stop_runtime_node,
)
from govoplan_core.db.session import get_database
logger = logging.getLogger("govoplan.runtime")
def application_runtime_identity(app: object) -> RuntimeIdentity:
"""Return the registered identity used to fence request-owned effects."""
state = getattr(app, "state", None)
agent = getattr(state, "govoplan_runtime_agent", None)
identity = getattr(agent, "identity", None) or getattr(
state,
"govoplan_runtime_identity",
None,
)
if not isinstance(identity, RuntimeIdentity):
raise RuntimeError("The application runtime identity is not available")
return identity
class RuntimeNodeAgent:
"""Register one process in the shared runtime directory and heartbeat it."""
def __init__(
self,
*,
settings: object,
software_version: str,
module_ids: tuple[str, ...],
role: str | None = None,
node_id: str | None = None,
queues: tuple[str, ...] | None = None,
metadata: dict[str, Any] | None = None,
identity: RuntimeIdentity | None = None,
) -> None:
self.settings = settings
self.identity: RuntimeIdentity = identity or runtime_identity(
settings,
software_version=software_version,
module_ids=module_ids,
role=role,
node_id=node_id,
queues=queues,
)
self.metadata = dict(metadata or {})
self.draining = False
self.coordination_healthy = False
self._task: asyncio.Task[None] | None = None
self._stopping = False
@property
def heartbeat_seconds(self) -> int:
return max(
2,
int(getattr(self.settings, "runtime_heartbeat_seconds", 15)),
)
async def start(self) -> None:
await asyncio.to_thread(self._register)
self._task = asyncio.create_task(
self._heartbeat_loop(),
name=f"govoplan-runtime-heartbeat:{self.identity.node_id}",
)
async def stop(self) -> None:
self._stopping = True
task = self._task
self._task = None
if task is not None:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
try:
await asyncio.to_thread(self._mark_stopped)
except Exception: # noqa: BLE001 - shutdown must continue
logger.exception(
"runtime node stop marker failed node_id=%s",
self.identity.node_id,
)
def _register(self) -> None:
with get_database().SessionLocal() as session:
node = register_runtime_node(
session,
self.identity,
metadata=self.metadata,
)
session.commit()
self.draining = node.state == "draining"
self.coordination_healthy = True
def _heartbeat(self) -> None:
with get_database().SessionLocal() as session:
node = heartbeat_runtime_node(
session,
self.identity,
metadata=self.metadata,
)
session.commit()
self.draining = node.state == "draining"
self.coordination_healthy = True
def _mark_stopped(self) -> None:
with get_database().SessionLocal() as session:
stop_runtime_node(session, self.identity)
session.commit()
async def _heartbeat_loop(self) -> None:
while not self._stopping:
await asyncio.sleep(self.heartbeat_seconds)
await self._heartbeat_once()
async def _heartbeat_once(self) -> bool:
try:
await asyncio.to_thread(self._heartbeat)
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 - a later heartbeat can recover
self.coordination_healthy = False
logger.exception(
"runtime heartbeat failed node_id=%s",
self.identity.node_id,
)
return False
self.coordination_healthy = True
return True
__all__ = ["RuntimeNodeAgent", "application_runtime_identity"]
+96 -3
View File
@@ -6,6 +6,40 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=None, extra="ignore")
app_env: str = Field(default="dev", alias="APP_ENV")
installation_id: str = Field(
default="govoplan-local",
alias="GOVOPLAN_INSTALLATION_ID",
)
state_profile: str = Field(
default="local",
alias="GOVOPLAN_STATE_PROFILE",
)
runtime_role: str = Field(default="api", alias="GOVOPLAN_RUNTIME_ROLE")
runtime_node_id: str | None = Field(default=None, alias="GOVOPLAN_NODE_ID")
runtime_heartbeat_seconds: int = Field(
default=15,
ge=2,
le=300,
alias="GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS",
)
runtime_stale_after_seconds: int = Field(
default=60,
ge=10,
le=3600,
alias="GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS",
)
runtime_expected_api_replicas: int = Field(
default=1,
ge=1,
le=1000,
alias="GOVOPLAN_EXPECTED_API_REPLICAS",
)
runtime_expected_worker_replicas: int = Field(
default=0,
ge=0,
le=1000,
alias="GOVOPLAN_EXPECTED_WORKER_REPLICAS",
)
module_live_apply_enabled: bool | None = Field(
default=None,
alias="GOVOPLAN_MODULE_LIVE_APPLY_ENABLED",
@@ -39,6 +73,30 @@ class Settings(BaseSettings):
ge=0,
le=86_400,
)
database_connection_limit: int | None = Field(
default=None,
alias="GOVOPLAN_DB_CONNECTION_LIMIT",
ge=10,
le=1_000_000,
)
database_connection_reserve: int = Field(
default=10,
alias="GOVOPLAN_DB_CONNECTION_RESERVE",
ge=1,
le=999_999,
)
database_connection_peak: int | None = Field(
default=None,
alias="GOVOPLAN_DB_CONNECTION_PEAK",
ge=1,
le=1_000_000,
)
database_connection_available: int | None = Field(
default=None,
alias="GOVOPLAN_DB_CONNECTION_AVAILABLE",
ge=1,
le=1_000_000,
)
access_database_url: str | None = Field(default=None, alias="ACCESS_DATABASE_URL")
access_db_schema: str | None = Field(default=None, alias="ACCESS_DB_SCHEMA")
access_table_prefix: str = Field(default="access_", alias="ACCESS_TABLE_PREFIX")
@@ -49,7 +107,7 @@ class Settings(BaseSettings):
default=(
"tenancy,organizations,identity,idm,access,admin,dashboard,policy,"
"audit,campaigns,files,mail,calendar,poll,scheduling,connectors,"
"datasources,dataflow,workflow_engine,workflow,views,search,risk_compliance,"
"datasources,dataflow,dist_lists,templates,workflow_engine,workflow,views,search,risk_compliance,"
"postbox,notifications,docs,ops"
),
alias="ENABLED_MODULES",
@@ -57,6 +115,12 @@ class Settings(BaseSettings):
migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK")
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
celery_visibility_timeout_seconds: int = Field(
default=3600,
ge=30,
le=7 * 24 * 60 * 60,
alias="CELERY_VISIBILITY_TIMEOUT_SECONDS",
)
s3_endpoint_url: str = Field(default="http://garage:3900", alias="S3_ENDPOINT_URL")
s3_region: str = Field(default="garage", alias="S3_REGION")
@@ -78,6 +142,10 @@ class Settings(BaseSettings):
default=False,
alias="FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
)
file_storage_s3_endpoint_trusted: bool = Field(
default=False,
alias="FILE_STORAGE_S3_ENDPOINT_TRUSTED",
)
file_upload_max_bytes: int = Field(default=50 * 1024 * 1024, alias="FILE_UPLOAD_MAX_BYTES")
file_upload_zip_max_bytes: int = Field(default=250 * 1024 * 1024, alias="FILE_UPLOAD_ZIP_MAX_BYTES")
file_archive_max_entries: int = Field(default=10_000, ge=1, alias="FILE_ARCHIVE_MAX_ENTRIES")
@@ -130,6 +198,18 @@ class Settings(BaseSettings):
le=100_000,
alias="AUTH_PRINCIPAL_CACHE_MAX_ENTRIES",
)
tenant_module_entitlement_cache_ttl_seconds: int = Field(
default=5,
ge=0,
le=300,
alias="TENANT_MODULE_ENTITLEMENT_CACHE_TTL_SECONDS",
)
tenant_module_entitlement_cache_max_entries: int = Field(
default=2048,
ge=1,
le=100_000,
alias="TENANT_MODULE_ENTITLEMENT_CACHE_MAX_ENTRIES",
)
auth_login_throttle_enabled: bool = Field(default=True, alias="AUTH_LOGIN_THROTTLE_ENABLED")
auth_login_throttle_identity_limit: int = Field(
default=10,
@@ -155,8 +235,8 @@ class Settings(BaseSettings):
master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64")
celery_queues: str = Field(
default=(
"send_email,append_sent,notifications,calendar,"
"dataflow,workflow,events,default"
"send_email,append_sent,notifications,mail,calendar,"
"dataflow,workflow,postbox,events,idm,default"
),
alias="CELERY_QUEUES",
)
@@ -191,6 +271,19 @@ class Settings(BaseSettings):
dev_bootstrap_password: str = Field(default="dev-admin", alias="DEV_BOOTSTRAP_PASSWORD")
dev_mailbox_api_enabled: bool = Field(default=False, alias="DEV_MAILBOX_API_ENABLED")
# Production first-administrator enrollment. The credential is issued only
# by the local operator command and is unrelated to development bootstrap.
first_admin_enrollment_ttl_seconds: int = Field(
default=30 * 60,
ge=60,
le=24 * 60 * 60,
alias="FIRST_ADMIN_ENROLLMENT_TTL_SECONDS",
)
first_admin_enrollment_file: str = Field(
default="/run/govoplan/first-admin-enrollment.json",
alias="FIRST_ADMIN_ENROLLMENT_FILE",
)
# Comma-separated list. Use * only for local development.
cors_origins: str = Field(default="http://localhost:5173,http://127.0.0.1:5173,http://localhost:8080", alias="CORS_ORIGINS")
+4
View File
@@ -426,6 +426,10 @@ class _FakeCampaignPolicyContextProvider:
class _FakeCampaignDeliveryTaskProvider:
def tenant_id_for_job(self, session: object, *, job_id: str):
del session, job_id
return "tenant-1"
def send_campaign_job(self, session: object, *, job_id: str, enqueue_imap_task: bool = True):
del session
return {"job_id": job_id, "enqueue_imap_task": enqueue_imap_task}

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