Compare commits

..
33 Commits
Author SHA1 Message Date
zemion b553513c9f Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:08:17 +02:00
zemion b5a4eb177a Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:34:47 +02:00
zemion f1a5be2a93 Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:53:46 +02:00
zemion add7a99f6d feat: add temporal context and contextual help 2026-08-05 00:03:31 +02:00
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
160 changed files with 14400 additions and 1084 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
+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,23 @@
from __future__ import annotations
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
_path = (
Path(__file__).resolve().parents[1]
/ "versions"
/ "a36d8e4f9b12_german_reference_locale.py"
)
_spec = spec_from_file_location("govoplan_german_reference_locale_migration", _path)
if _spec is None or _spec.loader is None:
raise RuntimeError(f"Unable to load migration implementation from {_path}")
_module = module_from_spec(_spec)
_spec.loader.exec_module(_module)
revision = _module.revision
down_revision = _module.down_revision
branch_labels = _module.branch_labels
depends_on = _module.depends_on
upgrade = _module.upgrade
downgrade = _module.downgrade
@@ -0,0 +1,23 @@
from __future__ import annotations
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
_path = (
Path(__file__).resolve().parents[1]
/ "versions"
/ "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,44 @@
"""adopt German as the untouched system reference locale
Revision ID: a36d8e4f9b12
Revises: f25c9d3e7a01
Create Date: 2026-08-05 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "a36d8e4f9b12"
down_revision = "f25c9d3e7a01"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
if "core_system_settings" not in set(sa.inspect(bind).get_table_names()):
return
settings = sa.table(
"core_system_settings",
sa.column("id", sa.String),
sa.column("default_locale", sa.String),
sa.column("created_at", sa.DateTime(timezone=True)),
sa.column("updated_at", sa.DateTime(timezone=True)),
)
bind.execute(
settings.update()
.where(settings.c.id == "global")
.where(settings.c.default_locale == "en")
.where(settings.c.created_at == settings.c.updated_at)
.values(default_locale="de", updated_at=sa.func.now())
)
def downgrade() -> None:
# Locale selection is user-visible state. A downgrade must not overwrite a
# German value that may have been selected explicitly after this migration.
pass
@@ -0,0 +1,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")
+21 -5
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,10 +92,14 @@ The runner should execute an action plan as follows:
4. Run permission and policy checks.
5. Generate a consequence preview.
6. Reserve or verify the idempotency key.
7. Execute the owning module capability.
8. Record observed effects.
9. Emit events and audit records.
10. Mark the command complete, retryable, quarantined, or requiring manual
7. Create a durable recovery operation and acquire its execution fence.
8. Persist dispatch evidence before a non-atomic provider call.
9. Execute the owning module capability.
10. Verify the provider result and every announced effect using the action's
declared recovery checks.
11. Commit the local projection and verified recovery checkpoint together.
12. Emit events and audit records.
13. Mark the command complete, retryable, quarantined, or requiring manual
intervention.
The runner must never advance workflow state past a required side effect unless
@@ -110,7 +117,16 @@ between:
6. reconciled, corrected, or compensated outcome.
An API timeout after dispatch is not a failed effect and must not be retried as
a fresh command. The actor context should retain the real identity/account,
an ordinary process failure or a fresh command. The runner records an unknown
outcome, releases its execution authority, and blocks continuation until an
operator or provider reconciliation proves either that the effect occurred or
that it is absent.
`ActionDefinition.recovery_mode` and `recovery_verification` are part of the
provider contract. The default is conservative forward recovery with explicit
provider-result and effect verification. Atomic mode is valid only when the
provider effect and its local projection share the same database transaction.
The actor context should retain the real identity/account,
represented function or party, delegation or power, and mandate/jurisdiction
references when applicable. Domain modules remain responsible for deciding
which of those references are required for their action.
+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
+65
View File
@@ -0,0 +1,65 @@
# Contextual Help Contract
GovOPlaN exposes context-sensitive help through `F1` and the titlebar help
control. The shell resolves a stable help identity from the focused control,
its containing surface, and the current route. The Docs module then projects
the best visible user or administrator topic for that identity.
## Resolution Order
The WebUI resolves help in this order:
1. an explicit `helpContextId` or `data-help-context-id` on the focused item
2. the focused shared control's `interfaceId`, `helpTopicId`, and label key
3. a containing dialog, card, administration section, or page surface
4. the current registered route, including dynamic module routes
5. a stable route-derived fallback when no explicit identity is available
Focused field and action contexts retain the page context as
`fallback_context`. This lets Docs show a field-specific topic when one exists
and otherwise open the owning page or module documentation instead of a generic
help page.
## Documentation Lookup
Static `DocumentationTopic` contributions announce exact contexts through
`metadata.help_contexts`. Core publishes that catalogue with the enabled module
manifest, allowing the shell to link directly to an exact topic when possible.
Docs still performs the authoritative audience, permission, configured-state,
and documentation-type filtering.
Core also maps explicit route, navigation, settings, and View surface IDs to
the module's static user or administrator documentation baseline. This makes a
page association complete by default and gives every derived field/action
context a useful fallback. Exact `metadata.help_contexts` remain the preferred
authoring mechanism for consequential or unfamiliar controls.
When there is no exact topic, Docs resolves the page fallback and then the first
visible topic owned by the module. If Docs is unavailable, the shell opens the
hosted documentation with the same context parameters.
## Authoring Controls
Core shared controls expose stable help metadata. Prefer these props rather
than adding custom `F1` listeners:
- `interfaceId` identifies a durable UI surface or action.
- `helpContextId` identifies a documentation context when it differs from the
interface identity.
- `helpTopicId` links directly to a module-owned documentation topic.
- translated label keys provide deterministic field identities for ordinary
`FormField`, `ToggleSwitch`, search, date/time, email, button, dialog, and card
controls.
Module routes, public routes, settings sections, and administration sections
may also declare `helpContextId` and `helpTopicId`. Each module must keep a
static user/admin documentation baseline and should list its important route,
workflow, setting, permission, and limitation identities in
`metadata.help_contexts`.
## Boundary
Help identities describe presentation context; they are not authorization
claims. Opening help never bypasses route or documentation permissions. Docs
owns documentation projection, feature modules own their content, and Core owns
focus capture, context resolution, and fallback routing.
+38 -2
View File
@@ -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
@@ -300,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.
@@ -431,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`
+8
View File
@@ -17,8 +17,13 @@ operator, and roadmap pages.
| Action/effect automation layer | `ACTION_EFFECT_AUTOMATION_LAYER.md` | Action/effect contracts, consequence preview, runner semantics, and module boundary for automation. |
| External references and integration maturity | `EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md` | Stable external identity and cumulative connector maturity; configured source authority is defined by the meta target architecture. |
| Institutional context and governed references | `INSTITUTIONAL_CONTEXT_CONTRACT.md` | Shared temporal, actor/representation, institution, mandate, service, party, decision, evidence, legal-basis, information-governance, presentation, and geo DTO/provider contracts. |
| Temporal data read context | `TEMPORAL_DATA_CONTEXT.md` | Valid-time and recorded-time titlebar selection, HTTP/cache contract, security boundary, and module-adoption rule. |
| Cross-module information governance adoption | `INFORMATION_GOVERNANCE_ADOPTION.md` | Manifest evidence and enforcement rules for temporal browsing, purpose-aware access, retention, and institutional context. |
| Context-sensitive F1 help | `CONTEXTUAL_HELP_CONTRACT.md` | Focus, route, module-manifest documentation contexts, Docs projection, and hosted fallback. |
| German localization and help quality gate | `LOCALIZATION_AND_HELP_QUALITY.md` | German reference locale, new-installation default, catalog completeness, automatic page associations, and explicit-help review priorities. |
| Postbox E2EE target architecture | `POSTBOX_E2EE_ARCHITECTURE.md` | Strategic encrypted postbox/mailbox model, key ownership, role mailbox semantics, and retraction limits. |
| Shared state, runtime coordination, and recovery | `STATE_AND_RECOVERY_CONTRACT.md` | State profiles, object storage, node registration/drain, fenced leases, migration ordering, and recovery evidence. |
| Module lifecycle recovery | `MODULE_LIFECYCLE_RECOVERY.md` | Installer/live-graph recovery modes, deployment fence, evidence, retry blocking, and operator reconciliation. |
## Release And Operations
@@ -36,8 +41,11 @@ operator, and roadmap pages.
| Topic | Canonical document | Notes |
| --- | --- | --- |
| Product roadmap and module routing | `GOVOPLAN_MASTER_ROADMAP.md` | Product-level sequencing, implementation gates, issue routing, and missing-module decisions. |
| Stable platform ideas | `govoplan/docs/PLATFORM_CORE_IDEAS.md` | Cross-product thesis, canonical distinctions, product experience rule, maturity rule, and decision test. |
| Current cross-product reconciliation | `govoplan/docs/STRATEGY_STATUS.md` | The only current prose status source; generated evidence and Gitea remain authoritative inputs. |
| 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. |
+19
View File
@@ -11,6 +11,9 @@ business-transaction rollback therefore cannot erase evidence of an earlier
effect. Successful completion requires concrete verification checks and a valid
hash chain. Compensation likewise records recovery-required, recovering, and
verified-recovered checkpoints rather than reporting an ordinary failure.
A definitive pre-effect or provider rejection records terminal `rejected`
evidence instead of being mislabeled as success, atomic rollback, or recovery
work.
If a runtime disappears, another runtime may claim the operation only after the
lease expires. The takeover records both fences. A stale compensatable operation
@@ -23,3 +26,19 @@ Evidence and metadata may contain opaque references, digests, counts, and
provider result codes. They must never contain credentials or resolved secrets.
Ops is the platform surface for unresolved operation status; owning modules must
provide the reconciliation action and business-level explanation.
Database-only operations must use the durable handle's atomic terminal methods
when their module rows and final recovery checkpoint belong to one invariant.
Those methods stage the terminal checkpoint and lease release in the caller's
SQLAlchemy transaction, then commit the domain rows and recovery evidence
together. A failed commit rolls both back and leaves the previously durable
`running` record available for stale-fence handling; modules must not commit
their domain state first and close an `atomic` recovery record afterwards.
An owning module may reconcile an `outcome_unknown` provider effect through the
claimed durable handle's `resolve_unknown` method. External evidence that the
effect occurred records verified success. Evidence that it did not occur moves
the operation through recovery-required and recovering to verified recovered,
so any later attempt must use a new deliberate idempotency key. The method does
not infer provider state and requires the same terminal verification structure
and hash-chain checks as ordinary completion.
+131
View File
@@ -0,0 +1,131 @@
# Information Governance Adoption
## Platform Rule
Temporal browsing, purpose-aware access, retention, and institutional context
are platform-wide information-governance dimensions. Every module receives the
same contract by default. A module may claim `partial` or `enforced` only with
repository-owned object scope, evidence, and limitations; it may claim
`not_applicable` only when the dimension genuinely does not apply.
Historical business data is always authorized under the current security
state. No module may use a historical permission, membership, role, function
assignment, or policy projection to weaken present-day access.
Platform-wide adoption is tracked in
[GovOPlaN #40](https://git.add-ideas.de/GovOPlaN/govoplan/issues/40), with
temporal reads detailed in
[GovOPlaN #39](https://git.add-ideas.de/GovOPlaN/govoplan/issues/39).
## Manifest Declaration
`ModuleManifest.information_governance` publishes four dimensions:
- `temporal_browsing`;
- `purpose_aware_access`;
- `retention`;
- `institutional_context`.
Each dimension declares:
- adoption: `not_applicable`, `contract_only`, `partial`, or `enforced`;
- object types covered;
- repository-local test/documentation evidence;
- the remaining limitation for `contract_only` or `partial`.
The default is intentionally `contract_only`. It applies the platform rule
without pretending that existing domain queries and effects already enforce
it. `reference_ready`, `supported`, and `lts` modules cannot retain an
applicable dimension below `enforced`.
## Read Contract
For every persistent domain object, the owner classifies the read:
1. **Current-only:** historical semantics do not exist and the API says so.
2. **Valid-time:** select facts effective now or at the requested instant.
3. **Bitemporal:** additionally select only revisions known by `recorded_at`.
4. **All-validity:** return effective revisions in a bounded history view.
The Core temporal middleware supplies the request context. Owners apply it in
repositories or query helpers, include it in cache keys, return evaluated
context, and test current/at/all plus recorded-time boundaries. Search,
reporting, exports, selectors, counts, and drill-through must use the same
projection as the owning list/detail API.
## Purpose-Aware Access Contract
Permission establishes a technical action ceiling. Purpose-aware access asks
whether this actor, represented capacity, case/work item, legal basis, and
declared use may access this object now.
- A client-supplied purpose is an assertion, never authority by itself.
- The owner or Policy capability validates the purpose and returns explainable
provenance.
- Sensitive access can require case assignment, mandate, reason capture,
approval, or break-glass evidence.
- Search, selectors, reporting, exports, background jobs, and connectors apply
the same decision.
- Audit records the validated purpose identifier and decision reference, not
unnecessary content.
## Retention Contract
Every persistent object declares an owner, retention class or policy reference,
trigger, start instant, hold behavior, review/disposition action, and evidence.
Retention is not a generic timestamp deletion job.
- Domain owners enumerate and execute their own effects through a typed
retention provider.
- Policy resolves inherited ceilings and simulation.
- Records owns record disposition; Files owns byte/object effects; Audit owns
audit-detail behavior; external providers declare their own effect and
recovery semantics.
- Dry-run, legal hold, exact revision, idempotency, outcome unknown,
reconciliation, correction, and destruction evidence are mandatory for
consequential removal.
## Institutional Context Contract
Consequential objects and effects carry the relevant tenant, institution,
organization unit, function, mandate/jurisdiction, service/case/work item,
party/representation, decision, and record references. Context is minimized to
what the operation needs. Organizational membership is not itself permission
or mandate.
Events, automation intents, audit evidence, records, and external effects retain
the same governed context envelope or an exact reference to it. Consumers must
not reconstruct authority later from mutable current structures.
## Adoption Order
1. Inventory every domain list/detail/search/export/effect and classify all
four dimensions.
2. Migrate institutional owners first: Access, IDM, Organizations, Mandates,
Services, Parties, Cases, Approvals, Committee, Decisions, Voting, and
Records.
3. Migrate communication and content: Addresses, Distribution Lists, Campaign,
Postbox, Mail, Calendar, Files, Templates, and Forms Runtime.
4. Migrate data projections: Connectors, Datasources, Dataflow, Reporting,
Search, Risk Compliance, and Dashboard.
5. Migrate workflow/task/background/provider operations and prove that no
asynchronous path drops context.
6. Advance manifest claims only after owner tests and browser/reference-journey
evidence pass.
The generated platform inventory reports adoption counts and module details.
Gitea tracks individual migrations; the declaration is evidence and a maturity
gate, not a substitute for implementation.
## Definition Of Enforced
A dimension is `enforced` only when:
- all declared object types and public reads/effects use it;
- list/detail/count/search/export/worker behavior is consistent;
- cache and pagination semantics cannot cross contexts;
- absence, invalid values, and inaccessible referenced context fail safely;
- tests cover current, historical, unauthorized, replay, and module-absence
combinations appropriate to the dimension;
- user/admin documentation explains behavior and limitations;
- the manifest cites those tests and docs.
+3
View File
@@ -8,6 +8,9 @@ The shared contract lives in `govoplan_core.core.institutional`.
Core owns reference shapes and provider protocols only. It does not own shared
Mandate, Service, Party, Decision, evidence, or geography tables. Domain modules
own persistence and authorization; optional capabilities resolve the references.
Interactive reads use the separate platform temporal-data context documented in
`TEMPORAL_DATA_CONTEXT.md`; it never changes current authorization or supplies
mutation dates.
## Envelope
+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, route/page/field/action F1 help, unsaved changes, confirmation, loading, alerts, problem lists, and policy provenance are centralized | Core component tests, `CONTEXTUAL_HELP_CONTRACT.md`, and module-permutation build |
## Boundary
Files and Mail are the first two external consumers of the layered
server/credential/policy pattern. Their own repositories retain provider
discovery, transport behavior, authorization, and migration evidence. Remaining
module surfaces are tracked by bounded module-owned issues under GovOPlaN #11;
they are not reasons to add sibling-private behavior to Core.
Raw JSON remains permitted only for diagnostics, expert inspection,
interchange, or conflict evidence. It is not a primary Core configuration
editor.
+60
View File
@@ -0,0 +1,60 @@
# Localization And Contextual Help Quality
## Reference Language
German (`de`) is GovOPlaN's first-class reference target. Every translation key
used by a shipped WebUI must exist in German and English. German completeness is
a release gate; English remains the source-code fallback language so existing
literal labels and external developer APIs do not change semantics.
New installations and tenants default to German. Existing system, tenant, and
user preferences are preserved. The available-language and policy model can
still select another default or disable a package at the relevant scope.
Explicit high-risk help content and browser acceptance are tracked in
[Core #284](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/284).
The platform inventory recognizes both inline locale objects and generated
catalogs declared as `const de` / `const en`. Its strict mode requires both
locales and reports `de` explicitly as the reference locale.
## Help Resolution
Every focusable field and action receives a stable derived F1 identity from the
shared shell, even when the component has no dedicated help text. Resolution
falls back from field/action to dialog or page and then to the module's visible
documentation baseline.
Backend manifests publish explicit topic associations first. Core additionally
associates declared route, navigation, settings, and View surface IDs with the
module's static user or administrator documentation baseline. Feature modules
should still add exact `metadata.help_contexts` entries for consequential,
unfamiliar, policy-controlled, destructive, security-sensitive, or legally
meaningful fields and actions.
The generated `help_review_candidates` list is therefore a content-depth queue,
not a list of controls on which F1 cannot work. It should prioritize:
1. effect, deletion, delivery, retention, disclosure, encryption, and recovery;
2. identity, representation, mandate, institutional context, and purpose;
3. valid-time versus recorded-time selection;
4. provider authority, synchronization, conflict, and outcome unknown;
5. fields whose consequences are not evident from their label.
## Verification
```bash
cd /mnt/DATA/git/govoplan
/mnt/DATA/git/govoplan/.venv/bin/python \
tools/inventory/platform-interface-inventory.py \
--strict --strict-declarations --strict-endpoints
```
The check must report:
- reference locale `de` present and complete;
- no used key missing from `de` or `en`;
- every field has a resolvable F1 context;
- no duplicate stable IDs;
- no undeclared public WebUI surface;
- no stale runtime route or endpoint declaration.
+85
View File
@@ -738,6 +738,13 @@ effects, transitions partial/unknown outcomes honestly, and records verified
completion or recovery. Plaintext secrets must never enter recovery metadata or
evidence.
For a conclusive external result, modules may commit their local success
projection and the verified terminal checkpoint in one database transaction via
`DurableRecoveryOperation.commit_verified_success`. This does not make the
external provider effect atomic. It prevents a local `succeeded` state from
becoming authoritative when the recovery evidence chain is damaged or the
terminal checkpoint cannot commit.
## Install, Uninstall, And Catalogs
Core owns the install plan, signed catalog validation, license entitlement
@@ -827,6 +834,24 @@ the shared loading and retryable error state around route rendering. The
initial static import closure and largest asynchronous chunk are enforced by
the budgets documented in [WEBUI_BUNDLE_BUDGETS.md](WEBUI_BUNDLE_BUDGETS.md).
Every public platform interface has a stable declaration identity. Backend
routes, capabilities, interfaces, search providers/sources, permissions,
frontend routes/navigation, and View surfaces derive that identity from typed
`ModuleManifest` values. Typed WebUI capabilities declare IDs for settings,
admin sections, widgets, search contexts, and extension actions. Shared form
and action controls accept `interfaceId` and `helpTopicId`; use module-namespaced
values when another contract, documentation topic, or automated check must
refer to the control across source changes. The static inventory assigns a
line-independent source anchor when an explicit ID is absent and reports that
fact for later review.
Core exposes the sanitized runtime declaration set at
`GET /api/v1/platform/interface-catalog`. The endpoint is read-only, requires
`admin:module:read` or `system:settings:read`, and includes only modules
effective in the caller's active tenant context. It never serializes factories,
credentials, executable callbacks, or mutable module state. Registry validation
rejects conflicting declaration IDs before startup.
WebUI modules receive only the core route context:
- `settings`
@@ -1232,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
@@ -1339,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
+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.
+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.
+14 -2
View File
@@ -28,6 +28,11 @@ module artifacts. It provides bounded read/write/list/stat/delete operations
for local and S3-compatible storage. Modules own their object-key namespace and
business metadata; Core does not interpret module files.
`stat` and `list_objects` return object size plus a UTC `modified_at` value when
the backend can prove it. Reconciliation and retention code may use that value
for conservative grace periods, but must treat a missing timestamp as
ineligible for automatic deletion rather than guessing an age.
Rules for modules:
- Store only opaque object keys in business records, never local absolute
@@ -88,10 +93,17 @@ Alembic, and post-migration tasks. The lock is session-scoped and therefore
released if the migration process dies.
Runtime roles run `govoplan_core.commands.wait_for_database`. It waits until
the database has exactly the configured Core/module Alembic heads and never
upgrades schema. This permits a migration Job and runtime Deployments to be
the database has exactly the configured, dependency-resolved Core/module
Alembic heads and never upgrades schema. Cross-module `depends_on` revisions
therefore do not leave runtime roles waiting for a branch marker Alembic has
correctly consumed. This permits a migration Job and runtime Deployments to be
submitted together while keeping startup fail-closed.
Runtime coordination records the installed `govoplan-core` distribution
version for API, worker and scheduler roles. FastAPI/OpenAPI metadata versions
are presentation metadata and must not be used as deployable software identity;
mixing the two would create a false version-skew readiness failure.
## Recovery Ledger
`govoplan_core.core.recovery` provides a durable operation and evidence
+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.
+68
View File
@@ -0,0 +1,68 @@
# Temporal Data Context
GovOPlaN exposes one read context for data validity and system knowledge. The
calendar control in the authenticated titlebar applies that context to
supported list and detail reads for the current account and tenant.
## Two Independent Axes
- **Valid time** answers when a fact applied in the represented domain.
- **Recorded time** answers what the system had recorded by a particular
instant.
The default is data valid now under the latest recorded state. `At time`
selects a valid-time instant. `All` removes the valid-time interval filter but
still uses the selected recorded state. The optional recorded-state cutoff can
be combined with any valid-time mode, which keeps correction history distinct
from changes in real-world validity.
An interval is half open: `valid_from <= instant < valid_to`. A revision belongs
to a recorded-state snapshot when `recorded_at <= cutoff` and it was not
superseded at or before that cutoff.
## Security And Mutation Rules
The temporal data context is a read projection, not an authorization context.
Authentication, permissions, active delegations, tenant boundaries, module
policy, and maintenance controls are always evaluated under current security
state. A historical projection never restores an expired permission.
The context also does not supply mutation dates. Writes continue to target the
current lifecycle revision and must carry their explicit valid/effective dates,
expected revision, reason, and evidence where the owning contract requires
them. A screen showing historical data must not silently turn a normal edit
into a historical correction.
## HTTP Contract
Core accepts these request headers:
| Header | Meaning |
| --- | --- |
| `X-Govoplan-Validity-Mode` | `current`, `at`, or `all` |
| `X-Govoplan-Valid-At` | Timezone-aware ISO 8601 instant required by `at` |
| `X-Govoplan-Recorded-At` | Optional timezone-aware system-knowledge cutoff |
Invalid or naive timestamps fail with HTTP 400. Responses expose the resolved
mode and evaluated instant. Conditional JSON responses vary by all three
request headers, and the shared WebUI API client includes them in request
deduplication and conditional-cache keys.
## Module Adoption
Revision-owning modules apply
`govoplan_core.db.temporal.apply_temporal_revision_filter` only to read queries
that are meant to follow the platform context. Explicit version references and
explicit resolver `effective_at` arguments take precedence. Current-row
lookups used for optimistic concurrency, authorization, routing, effects, or
other mutations must remain explicit and context-independent.
The initial bitemporal adoption covers Decisions, Mandates, Parties, and
Services. Their immutable revisions have indexed valid, recorded, and
superseded timestamps. Modules with effective-dated security records or
recorded-only revision histories require separate display-query adoption so
the global selector cannot affect current authorization or execution.
The WebUI selection is stored in session storage per account and tenant. A
change remounts the active module route so existing page loaders issue a fresh
request. Returning both axes to their defaults removes the stored selection.
+21 -5
View File
@@ -50,6 +50,13 @@ contestability, responsibility, and traceability at the point of action.
| UX-024 | Explicit `Discard` actions and dirty in-application navigation use the shared `UnsavedChangesProvider` dialog. A page registers save/discard behavior with `useUnsavedDraftGuard`; its Discard button calls `requestDiscard`, and route changes use `useGuardedNavigate` or `requestNavigation`. | Accepted | All create/edit surfaces |
| UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code |
| UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables |
| UX-027 | The platform icon rail keeps its brand header and utility footer visible. Only the module-navigation region scrolls when installed and permitted modules exceed the available viewport height. | Accepted | Core WebUI shell |
| UX-028 | Maintenance and offline states use their established textual warning banners centered in the titlebar. They must not recolor the shell or add decorative status icons. Global search is a right-side command, so warnings do not replace its trigger or overlay. | Accepted | Core WebUI shell |
| UX-029 | Recoverable page and module errors use the central compact `DismissibleAlert` presentation with an explicit recovery action where one exists. Full-height workspaces must overlay page feedback instead of allowing an alert to become a stretched workspace row. | Accepted | Core and module WebUIs |
| UX-030 | At narrow widths, the titlebar uses separate context and command rows. Context selectors remain horizontally reachable, search retains its compact trigger, and language/help/notification/account commands remain fixed icon controls without overlap. Shared content padding contracts so domain workspaces retain usable width. | Accepted | Core WebUI shell and all module workspaces |
| UX-031 | Public controls and extension contributions use stable, module-namespaced interface identities. Shared controls expose `interfaceId` and `helpTopicId`; generated source anchors are inventory evidence, not a substitute for an explicit ID when documentation, policy, or automation refers to the control. | Accepted | Core and module WebUIs |
| UX-032 | `F1` resolves help from the focused field or action, then its dialog/section/page and registered route. Focused contexts retain the page fallback; Docs applies audience and permission filtering and falls back to visible module documentation. | Accepted | Core shell, Docs, and all module WebUIs |
| UX-033 | Global search is the left-most titlebar command, immediately before language selection. Its icon, `F3`, and `Ctrl`/`Cmd`+`K` all open the same permission-aware search overlay; the titlebar does not reserve a persistent query field. | Accepted | Core shell and Search WebUI |
## Confirmed Implementation Decisions
@@ -223,6 +230,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 +283,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 +314,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 +350,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
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-core"
version = "0.1.14"
version = "0.1.18"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md"
requires-python = ">=3.12"
@@ -15,7 +15,7 @@ dependencies = [
"fastapi>=0.139,<1",
"pydantic>=2,<3",
"pydantic-settings>=2,<3",
"cryptography>=48.0.1,<50",
"cryptography>=50.0.0,<51",
"celery>=5,<6",
"redis>=5,<6",
"alembic>=1,<2",
@@ -37,6 +37,7 @@ govoplan_core = ["py.typed"]
[project.scripts]
govoplan-config = "govoplan_core.commands.config:main"
govoplan-devserver = "govoplan_core.devserver:main"
govoplan-first-admin = "govoplan_core.commands.first_admin:main"
govoplan-module-install-plan = "govoplan_core.commands.module_install_plan:main"
govoplan-module-installer = "govoplan_core.commands.module_installer:main"
+1 -2
View File
@@ -12,7 +12,7 @@ class SystemSettings(Base, TimestampMixin):
__tablename__ = "core_system_settings"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default="global")
default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False)
default_locale: Mapped[str] = mapped_column(String(20), default="de", nullable=False)
allow_tenant_custom_groups: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
allow_tenant_custom_roles: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
allow_tenant_api_keys: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
@@ -20,4 +20,3 @@ class SystemSettings(Base, TimestampMixin):
__all__ = ["SystemSettings"]
+4 -4
View File
@@ -84,7 +84,7 @@ class TenantInfo(BaseModel):
slug: str
name: str
is_active: bool = True
default_locale: str = "en"
default_locale: str = "de"
enabled_language_codes: list[str] = Field(default_factory=list)
@@ -210,7 +210,7 @@ class AuthProfileResponse(BaseModel):
active_tenant: TenantInfo
available_languages: list[LanguageInfo] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list)
default_language: str = "en"
default_language: str = "de"
profile_loaded: bool = True
@@ -239,7 +239,7 @@ class LoginResponse(BaseModel):
principal: PrincipalContextInfo | None = None
available_languages: list[LanguageInfo] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list)
default_language: str = "en"
default_language: str = "de"
profile_loaded: bool = True
roles_loaded: bool = True
groups_loaded: bool = True
@@ -257,7 +257,7 @@ class MeResponse(BaseModel):
principal: PrincipalContextInfo | None = None
available_languages: list[LanguageInfo] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list)
default_language: str = "en"
default_language: str = "de"
profile_loaded: bool = True
roles_loaded: bool = True
groups_loaded: bool = True
+4
View File
@@ -135,6 +135,9 @@ def get_api_principal(
authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
) -> ApiPrincipal:
cached = getattr(request.state, "govoplan_api_principal", None)
if isinstance(cached, ApiPrincipal):
return cached
principal = _api_principal_provider_from_request(request).resolve_api_principal(
request,
session,
@@ -143,6 +146,7 @@ def get_api_principal(
)
if not isinstance(principal, ApiPrincipal):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid API principal")
request.state.govoplan_api_principal = principal
return principal
File diff suppressed because it is too large Load Diff
+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()
@@ -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:
+34
View File
@@ -21,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"
@@ -45,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,
@@ -342,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
@@ -579,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]:
+24
View File
@@ -31,6 +31,13 @@ ActionReversibility = Literal[
"corrective_only",
"irreversible",
]
ActionRecoveryMode = Literal[
"atomic",
"compensation",
"snapshot_restore",
"forward_recovery",
"irreversible",
]
ActionExecutionState = Literal[
"pending",
"running",
@@ -87,6 +94,10 @@ class ActionDefinition:
idempotency_strategy: str = "caller_supplied"
audit_event_types: tuple[str, ...] = ()
preview_required: bool = True
recovery_mode: ActionRecoveryMode = "forward_recovery"
recovery_verification: tuple[str, ...] = (
"verify the provider result and every announced effect before continuation",
)
contract_version: str = ACTION_EFFECT_CONTRACT_VERSION
def __post_init__(self) -> None:
@@ -96,12 +107,24 @@ class ActionDefinition:
_require_text(self.description, "Action description")
_require_text(self.input_schema_ref, "Action input schema reference")
_require_text(self.idempotency_strategy, "Action idempotency strategy")
if self.recovery_mode not in {
"atomic",
"compensation",
"snapshot_restore",
"forward_recovery",
"irreversible",
}:
raise ValueError("Action recovery mode is not supported")
if any(not value.strip() for value in self.required_scopes):
raise ValueError("Action scopes must not be empty")
if any(not value.strip() for value in self.required_capabilities):
raise ValueError("Action capabilities must not be empty")
if any(not value.strip() for value in self.expected_effect_keys):
raise ValueError("Expected effect keys must not be empty")
if not self.recovery_verification or any(
not value.strip() for value in self.recovery_verification
):
raise ValueError("Actions must declare recovery verification steps")
@dataclass(frozen=True, slots=True)
@@ -384,6 +407,7 @@ __all__ = [
"AutomationPrincipalResolution",
"AutomationSubjectKind",
"ActionPreview",
"ActionRecoveryMode",
"ActionReversibility",
"ActionRiskLevel",
"EffectDefinition",
+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]:
...
+4
View File
@@ -52,6 +52,7 @@ class DataflowDatasetRequest:
row_limit: int = 500
expected_definition_hash: str | None = None
expected_source_fingerprints: tuple[Mapping[str, object], ...] = ()
run_ref: str | None = None
@dataclass(frozen=True, slots=True)
@@ -178,6 +179,7 @@ class DataflowTriggerDispatcher(Protocol):
self,
session: object,
*,
tenant_id: str | None = None,
now: datetime | None = None,
limit: int = 50,
) -> Mapping[str, object]:
@@ -204,6 +206,7 @@ class DataflowRunWorker(Protocol):
self,
session: object,
*,
tenant_id: str | None = None,
now: datetime | None = None,
limit: int = 10,
worker_id: str | None = None,
@@ -214,6 +217,7 @@ class DataflowRunWorker(Protocol):
self,
session: object,
*,
tenant_id: str | None = None,
now: datetime | None = None,
limit: int = 500,
) -> Mapping[str, object]:
+27
View File
@@ -9,6 +9,14 @@ 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"
@@ -265,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)
@@ -274,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)
@@ -339,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)
@@ -348,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)
@@ -356,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
+4
View File
@@ -182,6 +182,8 @@ class PlatformEventOutbox(Protocol):
self,
session: object,
*,
tenant_id: str | None = None,
tenantless_only: bool = False,
consumers: Sequence[DurableEventConsumer] = (),
observer: EventHandler | None = None,
limit: int = 100,
@@ -203,6 +205,8 @@ class PlatformEventOutbox(Protocol):
self,
session: object,
*,
tenant_id: str | None = None,
tenantless_only: bool = False,
before: datetime,
limit: int = 500,
) -> Mapping[str, int]:
+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="de",
settings={},
is_active=True,
)
session.add(tenant)
session.flush()
elif not tenant.is_active:
raise FirstAdminEnrollmentConflict("The selected initial tenant is inactive.")
try:
administrator = provisioner.create_first_system_administrator(
session,
tenant=tenant,
email=normalized_email,
display_name=clean_display_name,
password=password,
)
except FirstAdminProvisioningError as exc:
raise FirstAdminEnrollmentConflict(str(exc)) from exc
enrollment.state = FirstAdminEnrollmentState.CONSUMED.value
enrollment.consumed_at = current_time
enrollment.consumed_account_id = administrator.account_id
enrollment.consumed_membership_id = administrator.membership_id
enrollment.consumed_tenant_id = administrator.tenant_id
enrollment.consumed_email = administrator.email
enrollment.consumed_display_name = administrator.display_name
enrollment.consumed_request_sha256 = request_sha256
session.add(enrollment)
_append_event(
session,
enrollment,
event_type="administrator_created",
generation=enrollment.generation,
created_at=current_time,
evidence={
"account_id": administrator.account_id,
"membership_id": administrator.membership_id,
"tenant_id": administrator.tenant_id,
"email_sha256": hashlib.sha256(normalized_email.encode("utf-8")).hexdigest(),
},
)
audit_event(
session,
tenant_id=None,
scope="system",
action="access.first_admin_enrollment.completed",
object_type="access_account",
object_id=administrator.account_id,
details={
"generation": enrollment.generation,
"membership_id": administrator.membership_id,
"tenant_id": administrator.tenant_id,
"credential_invalidated": True,
},
)
return FirstAdminEnrollmentResult(administrator=administrator, replayed=False)
def _locked_enrollment(session: Session, installation_id: str) -> FirstAdminEnrollment:
enrollment = session.execute(
select(FirstAdminEnrollment)
.where(FirstAdminEnrollment.installation_id == installation_id)
.with_for_update()
).scalar_one_or_none()
if enrollment is not None:
return enrollment
enrollment = FirstAdminEnrollment(installation_id=installation_id)
try:
with session.begin_nested():
session.add(enrollment)
session.flush()
except IntegrityError:
enrollment = session.execute(
select(FirstAdminEnrollment)
.where(FirstAdminEnrollment.installation_id == installation_id)
.with_for_update()
).scalar_one()
return enrollment
def _append_event(
session: Session,
enrollment: FirstAdminEnrollment,
*,
event_type: str,
generation: int,
created_at: datetime,
evidence: dict[str, Any],
) -> None:
sequence = enrollment.event_count + 1
payload = {
"installation_id": enrollment.installation_id,
"sequence": sequence,
"event_type": event_type,
"generation": generation,
"created_at": created_at.isoformat(),
"evidence": evidence,
"previous_sha256": enrollment.evidence_head_sha256,
}
event_sha256 = hashlib.sha256(
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
session.add(
FirstAdminEnrollmentEvent(
installation_id=enrollment.installation_id,
sequence=sequence,
event_type=event_type,
generation=generation,
evidence=evidence,
previous_sha256=enrollment.evidence_head_sha256,
event_sha256=event_sha256,
created_at=created_at,
)
)
enrollment.event_count = sequence
enrollment.evidence_head_sha256 = event_sha256
session.add(enrollment)
def _request_sha256(
*,
email: str,
display_name: str | None,
tenant_slug: str,
tenant_name: str,
) -> str:
payload = {
"email": email,
"display_name": display_name,
"tenant_slug": tenant_slug,
"tenant_name": tenant_name,
}
return hashlib.sha256(
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
def _secret_sha256(secret: str) -> str:
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
def _utc(value: datetime | None) -> datetime:
candidate = value or datetime.now(timezone.utc)
if candidate.tzinfo is None:
return candidate.replace(tzinfo=timezone.utc)
return candidate.astimezone(timezone.utc)
def _is_future(value: datetime | None, now: datetime) -> bool:
return value is not None and _utc(value) > now
def _bounded_reason(value: str) -> str:
clean = value.strip()
if not clean:
raise ValueError("A local operator reason is required.")
return clean[:500]
__all__ = [
"FirstAdminEnrollment",
"FirstAdminEnrollmentConflict",
"FirstAdminEnrollmentCredentialError",
"FirstAdminEnrollmentError",
"FirstAdminEnrollmentEvent",
"FirstAdminEnrollmentResult",
"FirstAdminEnrollmentState",
"FirstAdminEnrollmentStatus",
"FirstAdminEnrollmentUnavailable",
"IssuedFirstAdminCredential",
"consume_first_admin_credential",
"first_admin_enrollment_status",
"issue_first_admin_credential",
]
@@ -0,0 +1,236 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal, Mapping, cast
InformationGovernanceAdoption = Literal[
"not_applicable",
"contract_only",
"partial",
"enforced",
]
INFORMATION_GOVERNANCE_ADOPTION_ORDER: tuple[InformationGovernanceAdoption, ...] = (
"not_applicable",
"contract_only",
"partial",
"enforced",
)
class InformationGovernanceDeclarationError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class InformationGovernanceDimension:
"""Truthful module-level adoption claim for one cross-cutting dimension."""
adoption: InformationGovernanceAdoption = "contract_only"
object_types: tuple[str, ...] = ()
evidence: tuple[str, ...] = ()
limitation: str | None = None
def __post_init__(self) -> None:
if self.adoption not in INFORMATION_GOVERNANCE_ADOPTION_ORDER:
raise InformationGovernanceDeclarationError(
f"Unsupported information-governance adoption: {self.adoption!r}."
)
for field_name in ("object_types", "evidence"):
values = getattr(self, field_name)
if len(values) != len(set(values)) or any(not item.strip() for item in values):
raise InformationGovernanceDeclarationError(
f"Information-governance {field_name.replace('_', ' ')} must "
"contain unique non-empty values."
)
if self.adoption == "enforced" and not self.evidence:
raise InformationGovernanceDeclarationError(
"An enforced information-governance dimension requires evidence."
)
if self.adoption in {"partial", "enforced"} and not self.object_types:
raise InformationGovernanceDeclarationError(
"Partial and enforced information-governance dimensions must "
"name their covered object types."
)
if self.adoption == "not_applicable" and self.object_types:
raise InformationGovernanceDeclarationError(
"A non-applicable information-governance dimension cannot declare object types."
)
if self.adoption in {"contract_only", "partial"} and not str(
self.limitation or ""
).strip():
raise InformationGovernanceDeclarationError(
"Contract-only and partial adoption must state the current limitation."
)
def to_dict(self) -> dict[str, object]:
return {
"adoption": self.adoption,
"object_types": list(self.object_types),
"evidence": list(self.evidence),
"limitation": self.limitation,
}
def _contract_only_dimension() -> InformationGovernanceDimension:
return InformationGovernanceDimension(
adoption="contract_only",
limitation=(
"The platform contract applies, but module-specific adoption evidence "
"has not been declared."
),
)
@dataclass(frozen=True, slots=True)
class ModuleInformationGovernance:
"""Cross-cutting data-use requirements and honest adoption evidence."""
temporal_browsing: InformationGovernanceDimension = field(
default_factory=_contract_only_dimension
)
purpose_aware_access: InformationGovernanceDimension = field(
default_factory=_contract_only_dimension
)
retention: InformationGovernanceDimension = field(
default_factory=_contract_only_dimension
)
institutional_context: InformationGovernanceDimension = field(
default_factory=_contract_only_dimension
)
current_authorization_for_historical_reads: bool = True
contract_version: str = "1"
def __post_init__(self) -> None:
if self.contract_version != "1":
raise InformationGovernanceDeclarationError(
"Unsupported module information-governance contract version."
)
if not self.current_authorization_for_historical_reads:
raise InformationGovernanceDeclarationError(
"Historical reads must always use current authorization."
)
for name, dimension in self.dimensions.items():
if not isinstance(dimension, InformationGovernanceDimension):
raise InformationGovernanceDeclarationError(
f"Information-governance dimension {name!r} has an invalid value."
)
@property
def dimensions(self) -> Mapping[str, InformationGovernanceDimension]:
return {
"temporal_browsing": self.temporal_browsing,
"purpose_aware_access": self.purpose_aware_access,
"retention": self.retention,
"institutional_context": self.institutional_context,
}
def to_dict(self) -> dict[str, object]:
return {
"contract_version": self.contract_version,
"current_authorization_for_historical_reads": (
self.current_authorization_for_historical_reads
),
"dimensions": {
name: dimension.to_dict()
for name, dimension in self.dimensions.items()
},
}
def information_governance_from_mapping(
value: Mapping[str, object],
) -> ModuleInformationGovernance:
raw_dimensions = value.get("dimensions")
if not isinstance(raw_dimensions, Mapping):
raise InformationGovernanceDeclarationError(
"Information-governance dimensions must be an object."
)
def dimension(name: str) -> InformationGovernanceDimension:
raw_dimension = raw_dimensions.get(name)
if not isinstance(raw_dimension, Mapping):
raise InformationGovernanceDeclarationError(
f"Information-governance dimension {name!r} must be an object."
)
def text_tuple(field_name: str) -> tuple[str, ...]:
raw_values = raw_dimension.get(field_name, ())
if not isinstance(raw_values, (list, tuple)):
raise InformationGovernanceDeclarationError(
f"Information-governance {name}.{field_name} must be a list."
)
if any(not isinstance(item, str) for item in raw_values):
raise InformationGovernanceDeclarationError(
f"Information-governance {name}.{field_name} must contain strings."
)
return tuple(raw_values)
raw_limitation = raw_dimension.get("limitation")
raw_adoption = raw_dimension.get("adoption") or "contract_only"
if not isinstance(raw_adoption, str):
raise InformationGovernanceDeclarationError(
f"Information-governance {name}.adoption must be a string."
)
if raw_limitation is not None and not isinstance(raw_limitation, str):
raise InformationGovernanceDeclarationError(
f"Information-governance {name}.limitation must be a string."
)
return InformationGovernanceDimension(
adoption=cast(
InformationGovernanceAdoption,
raw_adoption,
),
object_types=text_tuple("object_types"),
evidence=text_tuple("evidence"),
limitation=raw_limitation,
)
current_authorization = value.get(
"current_authorization_for_historical_reads",
True,
)
if not isinstance(current_authorization, bool):
raise InformationGovernanceDeclarationError(
"current_authorization_for_historical_reads must be boolean."
)
return ModuleInformationGovernance(
contract_version=str(value.get("contract_version") or "1"),
current_authorization_for_historical_reads=current_authorization,
temporal_browsing=dimension("temporal_browsing"),
purpose_aware_access=dimension("purpose_aware_access"),
retention=dimension("retention"),
institutional_context=dimension("institutional_context"),
)
def information_governance_maturity_issues(
declaration: ModuleInformationGovernance,
*,
maturity: str | None,
) -> tuple[str, ...]:
if maturity not in {"reference_ready", "supported", "lts"}:
return ()
incomplete = [
name
for name, dimension in declaration.dimensions.items()
if dimension.adoption not in {"not_applicable", "enforced"}
]
if not incomplete:
return ()
return (
f"Maturity {maturity!r} requires enforced or explicitly non-applicable "
"information governance for: " + ", ".join(incomplete),
)
__all__ = [
"INFORMATION_GOVERNANCE_ADOPTION_ORDER",
"InformationGovernanceAdoption",
"InformationGovernanceDeclarationError",
"InformationGovernanceDimension",
"ModuleInformationGovernance",
"information_governance_from_mapping",
"information_governance_maturity_issues",
]
+164 -7
View File
@@ -1,18 +1,32 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from collections.abc import AsyncIterator, Mapping, Sequence
from dataclasses import dataclass
from threading import RLock
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, status
from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException, Request, status
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.module_management import ModuleManagementError, REQUIRED_PLATFORM_MODULES, plan_desired_enabled_modules
from govoplan_core.core.module_entitlements import (
ModuleEntitlementResolutionError,
TenantModuleUnavailable,
tenant_execution_scope,
)
from govoplan_core.core.module_lifecycle_recovery import (
ModuleLifecycleRecovery,
begin_runtime_graph_recovery,
canonical_sha256,
)
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import configure_runtime
from govoplan_core.core.workflows import (
workflow_definition_contribution_provider,
)
from govoplan_core.db.session import get_session
from govoplan_core.server.route_validation import validate_router_can_mount
@@ -26,12 +40,75 @@ class ModuleLifecycleResult:
def require_module_active(module_id: str):
def dependency(request: Request) -> None:
async def dependency(
request: Request,
session: Session = Depends(get_session),
authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
) -> AsyncIterator[None]:
registry = getattr(request.app.state, "govoplan_registry", None)
if isinstance(registry, PlatformRegistry) and registry.has_module(module_id):
return
if not isinstance(registry, PlatformRegistry) or not registry.has_module(module_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Module is disabled: {module_id}")
tenant_id: str | None = None
if not authorization and not x_api_key and not request.cookies:
public_resolver = registry.public_tenant_resolver(module_id)
if public_resolver is not None:
tenant_id = public_resolver(request, session)
if tenant_id is None:
yield
return
else:
try:
principal = get_api_principal(
request,
session,
authorization=authorization,
x_api_key=x_api_key,
)
except HTTPException as exc:
if exc.status_code in {
status.HTTP_401_UNAUTHORIZED,
status.HTTP_403_FORBIDDEN,
}:
yield
return
raise
if (
not isinstance(principal, ApiPrincipal)
or principal.principal.tenant_id is None
):
yield
return
tenant_id = principal.principal.tenant_id
resolver = registry.tenant_entitlement_resolver()
try:
admission = resolver.require(
session,
tenant_id=tenant_id,
module_id=module_id,
work_state="interactive",
)
except TenantModuleUnavailable as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Module is unavailable in the active tenant: {module_id}",
) from exc
except (ModuleEntitlementResolutionError, RuntimeError, SQLAlchemyError) as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Tenant module entitlement could not be resolved.",
) from exc
request.state.govoplan_module_admission = admission
with tenant_execution_scope(
resolver,
session,
tenant_id=tenant_id,
work_state="interactive",
):
yield
return dependency
@@ -99,13 +176,40 @@ class ModuleLifecycleManager:
next_set = set(plan.enabled_modules)
activated = tuple(module_id for module_id in plan.enabled_modules if module_id not in previous_set)
deactivated = tuple(module_id for module_id in previous if module_id not in next_set)
graph_changes = bool(activated or deactivated)
recovery: ModuleLifecycleRecovery | None = None
if graph_changes or migrate:
from govoplan_core.db.session import get_database
with get_database().session() as recovery_session:
recovery = begin_runtime_graph_recovery(
recovery_session,
previous_modules=previous,
requested_modules=plan.enabled_modules,
migrate=migrate,
)
old_manifests = {
manifest.id: manifest for manifest in self.registry.manifests()
}
try:
if recovery is not None:
recovery.checkpoint(
kind="runtime-graph-effect-started",
summary="Runtime module graph entered its mutation boundary",
evidence={
"activated_sha256": canonical_sha256(activated),
"deactivated_sha256": canonical_sha256(deactivated),
"migrate": migrate,
},
effect_started=True,
)
if migrate:
self._migrate(plan.enabled_modules)
mounted = tuple(module_id for module_id in plan.enabled_modules if self._mount_module_router(module_id))
old_manifests = {manifest.id: manifest for manifest in self.registry.manifests()}
for module_id in deactivated:
hook = old_manifests[module_id].on_deactivate
if hook is not None:
@@ -119,11 +223,64 @@ class ModuleLifecycleManager:
if hook is not None:
hook(self.context)
self.reconcile_workflow_definitions()
reconciliation = self.reconcile_workflow_definitions()
if self._app is not None:
self._app.openapi_schema = None
if recovery is not None:
from govoplan_core.db.session import get_database
with get_database().session() as recovery_session:
recovery.succeed(
recovery_session,
evidence={
"active_graph_sha256": canonical_sha256(
self.active_module_ids()
),
"mounted_graph_sha256": canonical_sha256(
self.mounted_module_ids()
),
"workflow_reconciliation_sha256": canonical_sha256(
reconciliation
),
},
commit_projection=False,
)
except Exception as exc:
self.registry.replace(old_manifests.values())
self.configure_runtime()
if self._app is not None:
self._app.openapi_schema = None
if recovery is not None:
from govoplan_core.db.session import get_database
recovery.unresolved(
summary="Runtime graph mutation did not reach verified completion",
evidence={
"error_type": type(exc).__name__,
"previous_graph_sha256": canonical_sha256(previous),
"registry_restored": True,
"migrate": migrate,
},
outcome_unknown=migrate,
)
if not migrate:
with get_database().session() as recovery_session:
recovery.recovered(
recovery_session,
evidence={
"active_graph_sha256": canonical_sha256(
self.active_module_ids()
),
"previous_graph_restored": (
self.active_module_ids() == previous
),
},
summary="Previous runtime module graph was restored",
)
raise
return ModuleLifecycleResult(
enabled_modules=plan.enabled_modules,
activated_modules=activated,
+1
View File
@@ -56,6 +56,7 @@ class MailDeliveryOutboxProvider(Protocol):
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 250,
) -> Mapping[str, object]:
...
@@ -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",
]
+313 -10
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:
@@ -503,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)
@@ -520,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,
@@ -550,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,
@@ -569,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,
@@ -605,13 +622,39 @@ def _prepare_module_install_run(
verify_modules=True,
)
record_redactions = _installer_secret_redactions(database_url)
record = _initial_module_install_record(
recovery: ModuleLifecycleRecovery | None = None
if not dry_run:
try:
recovery = begin_module_installer_recovery(
session,
run_id=run_id,
plan=plan,
preflight=preflight,
commands=commands,
record_redactions=record_redactions,
snapshot=_snapshot_environment(
plan=tuple(item.as_dict() for item in plan.items),
command_count=len(commands),
migrate_database=migrate_database,
destructive_retirement=_destructive_retirement_requested(plan),
snapshot_sha256=None,
backup_reference=(
f"module-installer:{run_id}:database-backup"
if _destructive_retirement_requested(plan)
else None
),
request_context_sha256=canonical_sha256(dict(request_context or {})),
)
recovery.checkpoint(
kind="snapshot-started",
summary="Installer environment snapshot started before package effects",
evidence={
"run_id": run_id,
"database_backup_expected": bool(
migrate_database or _destructive_retirement_requested(plan)
),
},
)
except ModuleLifecycleRecoveryError as exc:
raise ModuleInstallerError(str(exc)) from exc
try:
snapshot = _snapshot_environment(
run_dir,
webui_root=webui_root,
database_url=database_url,
@@ -619,7 +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,
@@ -627,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(
@@ -637,6 +707,7 @@ def _prepare_module_install_run(
result_commands=_command_displays(commands, redactions=record_redactions),
record_redactions=record_redactions,
record=record,
recovery=recovery,
)
@@ -673,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,
@@ -685,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
@@ -740,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]],
@@ -765,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,
@@ -775,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",
@@ -783,6 +991,7 @@ def _failed_module_install_run_result(
return_code=1,
error=failed_error,
rollback=rollback.as_dict(),
recovery=state.recovery,
)
@@ -795,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:
@@ -806,14 +1016,50 @@ def _applied_module_install_run_result(
)
save_desired_enabled_modules(session, next_desired)
state.record["desired_enabled_after"] = list(next_desired)
recovery_evidence = {
"command_count": len(executed),
"command_results_sha256": canonical_sha256(executed),
"desired_graph_sha256": canonical_sha256(
state.record.get("desired_enabled_after", list(desired_enabled))
),
"plan_projection_sha256": canonical_sha256(
[item.as_dict() for item in plan.items]
),
}
if state.recovery is not None and finalize_recovery:
state.recovery.succeed(
session,
evidence=recovery_evidence,
commit_projection=True,
)
recovery_status = "succeeded"
else:
session.commit()
recovery_status = "awaiting_supervisor" if state.recovery is not None else None
if state.recovery is not None:
state.recovery.checkpoint(
kind="local-projection-committed",
summary="Package and desired-graph projections await runtime health verification",
evidence=recovery_evidence,
)
state.record.update({
"status": "applied",
"finished_at": datetime.now(tz=UTC).isoformat(),
"commands": executed,
})
if state.recovery is not None and recovery_status is not None:
state.record["recovery"] = _module_lifecycle_recovery_record(
state.recovery,
status=recovery_status,
)
_write_json(state.record_path, state.record)
return ModuleInstallerRunResult(run_id=state.run_id, status="applied", record_path=state.record_path, commands=state.result_commands)
return ModuleInstallerRunResult(
run_id=state.run_id,
status="applied",
record_path=state.record_path,
commands=state.result_commands,
recovery=state.recovery,
)
def supervise_module_install_plan(
@@ -864,6 +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(),
@@ -938,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
@@ -3264,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
@@ -3284,6 +3577,7 @@ def _rollback_after_supervisor_failure(
return_code=1,
error=reason,
rollback=rollback.as_dict(),
recovery=result.recovery,
)
@@ -3698,10 +3992,13 @@ def _snapshot_sqlite_database(run_dir: Path, database_url: str | None) -> dict[s
raise ModuleInstallerError(
f"SQLite backup failed its restore-readiness integrity check: {integrity}"
)
artifact_sha256 = _sha256_file(backup_path)
return {
"type": "sqlite",
"source": str(db_path),
"path": backup_path.name,
"artifact_sha256": artifact_sha256,
"size_bytes": backup_path.stat().st_size,
"restore_check": {
"type": "sqlite_integrity_check",
"result": integrity,
@@ -3744,6 +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",
]
@@ -17,6 +17,10 @@ from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
from govoplan_core.core.information_governance import (
information_governance_from_mapping,
information_governance_maturity_issues,
)
from govoplan_core.core.provider_governance import (
external_provider_from_mapping,
module_architecture_from_mapping,
@@ -630,6 +634,7 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
"tags": _string_list(value.get("tags")),
}
raw_architecture = value.get("architecture")
architecture_maturity: str | None = None
if raw_architecture is not None:
if not isinstance(raw_architecture, Mapping):
raise ValueError(
@@ -647,6 +652,27 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
+ "; ".join(issues)
)
item["architecture"] = architecture.to_dict()
architecture_maturity = architecture.maturity
raw_information_governance = value.get("information_governance")
if raw_information_governance is not None:
if not isinstance(raw_information_governance, Mapping):
raise ValueError(
"Module package catalog information_governance for "
f"{module_id!r} must be an object."
)
information_governance = information_governance_from_mapping(
raw_information_governance
)
governance_issues = information_governance_maturity_issues(
information_governance,
maturity=architecture_maturity,
)
if governance_issues:
raise ValueError(
"Module package catalog information_governance for "
f"{module_id!r} is invalid: " + "; ".join(governance_issues)
)
item["information_governance"] = information_governance.to_dict()
raw_providers = value.get("external_providers")
if raw_providers is not None:
if not isinstance(raw_providers, list):
+6
View File
@@ -4,6 +4,7 @@ from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any, Literal, Protocol, TYPE_CHECKING
from govoplan_core.core.information_governance import ModuleInformationGovernance
from govoplan_core.core.ownership import OwnershipProviderRegistration
from govoplan_core.core.provider_governance import (
ExternalProviderDeclaration,
@@ -408,6 +409,7 @@ class DeleteVetoProviderRegistration:
RouteFactory = Callable[[ModuleContext], "APIRouter"]
CapabilityFactory = Callable[[ModuleContext], object]
PublicTenantResolver = Callable[[object, object], str | None]
DocumentationProvider = Callable[[DocumentationContext], Iterable[DocumentationTopic]]
LifecycleHook = Callable[[ModuleContext], None]
@@ -426,6 +428,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
@@ -444,6 +447,9 @@ class ModuleManifest:
...,
] = ()
architecture: ModuleArchitectureDeclaration | None = None
information_governance: ModuleInformationGovernance = field(
default_factory=ModuleInformationGovernance
)
external_providers: tuple[ExternalProviderDeclaration, ...] = ()
external_provider_state_providers: tuple[
ExternalProviderStateProviderRegistration,
+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,
+28 -3
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from heapq import nsmallest
import os
from pathlib import Path
@@ -27,6 +28,7 @@ class StorageObjectMissing(StorageBackendError):
class StorageObjectInfo:
key: str
size_bytes: int
modified_at: datetime | None = None
@dataclass(frozen=True, slots=True)
@@ -165,9 +167,14 @@ class LocalFilesystemStorageBackend:
def stat(self, key: str) -> StorageObjectInfo:
path = self._readable_path(key)
metadata = path.stat()
return StorageObjectInfo(
key=normalize_storage_key(key),
size_bytes=path.stat().st_size,
size_bytes=metadata.st_size,
modified_at=datetime.fromtimestamp(
metadata.st_mtime,
tz=timezone.utc,
),
)
def list_objects(
@@ -188,9 +195,14 @@ class LocalFilesystemStorageBackend:
normalized_after is not None and key <= normalized_after
):
continue
metadata = path.stat()
yield StorageObjectInfo(
key=key,
size_bytes=path.stat().st_size,
size_bytes=metadata.st_size,
modified_at=datetime.fromtimestamp(
metadata.st_mtime,
tz=timezone.utc,
),
)
candidates = nsmallest(
@@ -381,7 +393,11 @@ class S3StorageBackend:
raise StorageBackendError(
"S3 object metadata did not include a valid size"
) from exc
return StorageObjectInfo(key=normalized, size_bytes=size)
return StorageObjectInfo(
key=normalized,
size_bytes=size,
modified_at=_storage_modified_at(response.get("LastModified")),
)
def list_objects(
self,
@@ -407,6 +423,7 @@ class S3StorageBackend:
StorageObjectInfo(
key=str(item["Key"]),
size_bytes=int(item.get("Size") or 0),
modified_at=_storage_modified_at(item.get("LastModified")),
)
for item in response.get("Contents", ())
if isinstance(item, dict) and item.get("Key")
@@ -418,6 +435,14 @@ class S3StorageBackend:
)
def _storage_modified_at(value: object) -> datetime | None:
if not isinstance(value, datetime):
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def configured_storage_backend(settings: object) -> StorageBackend:
"""Build the deployment-wide object store from Core settings.
@@ -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",
]
@@ -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",
+14 -3
View File
@@ -41,6 +41,7 @@ class RecoveryStatus(StrEnum):
PREPARED = "prepared"
RUNNING = "running"
SUCCEEDED = "succeeded"
REJECTED = "rejected"
FAILED = "failed"
OUTCOME_UNKNOWN = "outcome_unknown"
RECOVERY_REQUIRED = "recovery_required"
@@ -52,6 +53,7 @@ class RecoveryStatus(StrEnum):
TERMINAL_RECOVERY_STATUSES = frozenset(
{
RecoveryStatus.SUCCEEDED.value,
RecoveryStatus.REJECTED.value,
RecoveryStatus.FAILED.value,
RecoveryStatus.RECOVERED.value,
RecoveryStatus.MANUAL_INTERVENTION.value,
@@ -69,6 +71,7 @@ _TRANSITIONS: dict[str, frozenset[str]] = {
RecoveryStatus.RUNNING.value: frozenset(
{
RecoveryStatus.SUCCEEDED.value,
RecoveryStatus.REJECTED.value,
RecoveryStatus.FAILED.value,
RecoveryStatus.OUTCOME_UNKNOWN.value,
RecoveryStatus.RECOVERY_REQUIRED.value,
@@ -431,7 +434,11 @@ def transition_recovery_operation(
elif status == RecoveryStatus.RECOVERED:
locked.recovered_at = observed_at
locked.completed_at = observed_at
elif status in {RecoveryStatus.FAILED, RecoveryStatus.MANUAL_INTERVENTION}:
elif status in {
RecoveryStatus.REJECTED,
RecoveryStatus.FAILED,
RecoveryStatus.MANUAL_INTERVENTION,
}:
locked.completed_at = observed_at
session.add(locked)
record_recovery_checkpoint(
@@ -591,7 +598,11 @@ def _validate_transition_evidence(
evidence: dict[str, Any],
failure_summary: str | None,
) -> None:
if status in {RecoveryStatus.SUCCEEDED, RecoveryStatus.RECOVERED}:
if status in {
RecoveryStatus.SUCCEEDED,
RecoveryStatus.REJECTED,
RecoveryStatus.RECOVERED,
}:
checks = evidence.get("checks")
if (
evidence.get("verified") is not True
@@ -602,7 +613,7 @@ def _validate_transition_evidence(
or not checks
):
raise RecoveryGuaranteeError(
"Successful recovery transitions require verified evidence and check results"
"Verified terminal transitions require verified evidence and check results"
)
if status == RecoveryStatus.MANUAL_INTERVENTION and not failure_summary:
raise RecoveryGuaranteeError(
+295
View File
@@ -105,6 +105,121 @@ class DurableRecoveryOperation:
session.commit()
self.closed = True
def commit_atomic_success(
self,
session: Session,
*,
evidence: dict[str, Any],
) -> None:
"""Commit domain writes and verified success in one DB transaction."""
self._commit_terminal(
session,
status=RecoveryStatus.SUCCEEDED,
summary="Operation effects and authoritative state were verified",
kind="verified-success",
evidence=evidence,
require_atomic_mode=True,
)
def commit_verified_success(
self,
session: Session,
*,
evidence: dict[str, Any],
) -> None:
"""Commit a verified success projection and checkpoint together.
Non-atomic operations use this only after their external effect has a
conclusive provider result. It does not make that effect atomic; it
prevents local success from outrunning its durable verification.
"""
self._commit_terminal(
session,
status=RecoveryStatus.SUCCEEDED,
summary="Operation effects and authoritative state were verified",
kind="verified-success",
evidence=evidence,
require_atomic_mode=False,
)
def commit_atomic_failure(
self,
session: Session,
*,
summary: str,
evidence: dict[str, Any],
) -> None:
"""Commit domain failure evidence and the terminal state atomically."""
self._commit_terminal(
session,
status=RecoveryStatus.FAILED,
summary=summary,
kind="verified-failure",
evidence=evidence,
require_atomic_mode=True,
)
def commit_atomic_rejection(
self,
session: Session,
*,
summary: str,
evidence: dict[str, Any],
) -> None:
"""Commit a definitive rejection and its domain evidence atomically."""
self._commit_terminal(
session,
status=RecoveryStatus.REJECTED,
summary=summary,
kind="verified-rejection",
evidence=evidence,
require_atomic_mode=True,
)
def fail(self, *, summary: str, evidence: dict[str, Any]) -> None:
"""Finish a verified, ordinary failure that needs no recovery."""
with self.session_factory() as session:
operation, claim = self._locked_and_renewed(session)
transition_recovery_operation(
session,
operation,
status=RecoveryStatus.FAILED,
kind="verified-failure",
summary=summary,
evidence=evidence,
failure_summary=summary,
lease_claim=claim,
)
self._verify_chain(session)
release_lease(session, claim)
session.commit()
self.closed = True
def reject(self, *, summary: str, evidence: dict[str, Any]) -> None:
"""Finish an operation with a verified definitive rejection."""
with self.session_factory() as session:
operation, claim = self._locked_and_renewed(session)
transition_recovery_operation(
session,
operation,
status=RecoveryStatus.REJECTED,
kind="verified-rejection",
summary=summary,
evidence=evidence,
failure_summary=summary,
lease_claim=claim,
)
self._verify_chain(session)
release_lease(session, claim)
session.commit()
self.closed = True
def compensate(
self,
*,
@@ -179,6 +294,58 @@ class DurableRecoveryOperation:
session.commit()
self.closed = True
def resolve_unknown(
self,
*,
effect_occurred: bool,
evidence: dict[str, Any],
summary: str,
) -> None:
"""Resolve an externally verified operation with an unknown outcome.
A confirmed provider effect is a verified success. A confirmed absence
of the effect is recorded as forward recovery: the declared invariant
is restored and the original effect may be attempted again under a new
idempotency key.
"""
with self.session_factory() as session:
try:
self._transition_unknown_resolution(
session,
effect_occurred=effect_occurred,
evidence=evidence,
summary=summary,
)
session.commit()
except Exception:
session.rollback()
raise
self.closed = True
def commit_unknown_resolution(
self,
session: Session,
*,
effect_occurred: bool,
evidence: dict[str, Any],
summary: str,
) -> None:
"""Commit an operator reconciliation and its domain projection together."""
try:
self._transition_unknown_resolution(
session,
effect_occurred=effect_occurred,
evidence=evidence,
summary=summary,
)
session.commit()
except Exception:
session.rollback()
raise
self.closed = True
def release_unresolved(self) -> None:
"""Release authority after a process-local exception.
@@ -218,10 +385,111 @@ class DurableRecoveryOperation:
select(RecoveryOperation)
.where(RecoveryOperation.id == self.operation_id)
.with_for_update()
.execution_options(populate_existing=True)
).scalar_one()
self.lease_claim = claim
return operation, claim
def _commit_terminal(
self,
session: Session,
*,
status: RecoveryStatus,
summary: str,
kind: str,
evidence: dict[str, Any],
require_atomic_mode: bool,
) -> None:
if status not in {
RecoveryStatus.SUCCEEDED,
RecoveryStatus.FAILED,
RecoveryStatus.REJECTED,
}:
raise ValueError("Unsupported terminal recovery status")
try:
operation, claim = self._locked_and_renewed(session)
if (
require_atomic_mode
and operation.mode != RecoveryMode.ATOMIC.value
):
raise RecoveryGuaranteeError(
"Atomic terminal commits require an atomic recovery plan"
)
transition_recovery_operation(
session,
operation,
status=status,
kind=kind,
summary=summary,
evidence=evidence,
failure_summary=(
summary
if status in {RecoveryStatus.FAILED, RecoveryStatus.REJECTED}
else None
),
lease_claim=claim,
)
self._verify_chain(session)
release_lease(session, claim)
session.commit()
except Exception:
session.rollback()
raise
self.closed = True
def _transition_unknown_resolution(
self,
session: Session,
*,
effect_occurred: bool,
evidence: dict[str, Any],
summary: str,
) -> None:
operation, claim = self._locked_and_renewed(session)
if operation.status != RecoveryStatus.OUTCOME_UNKNOWN.value:
raise RecoveryOperationStateConflict(operation.id, operation.status)
if effect_occurred:
transition_recovery_operation(
session,
operation,
status=RecoveryStatus.SUCCEEDED,
kind="unknown-outcome-verified-success",
summary=summary,
evidence=evidence,
lease_claim=claim,
)
else:
operation = transition_recovery_operation(
session,
operation,
status=RecoveryStatus.RECOVERY_REQUIRED,
kind="unknown-outcome-recovery-required",
summary=summary,
evidence=evidence,
failure_summary="The external effect was verified absent",
lease_claim=claim,
)
operation = transition_recovery_operation(
session,
operation,
status=RecoveryStatus.RECOVERING,
kind="unknown-outcome-recovery-started",
summary="Recording the verified absence of the external effect",
evidence={"effect_occurred": False},
lease_claim=claim,
)
transition_recovery_operation(
session,
operation,
status=RecoveryStatus.RECOVERED,
kind="unknown-outcome-verified-absent",
summary=summary,
evidence=evidence,
lease_claim=claim,
)
self._verify_chain(session)
release_lease(session, claim)
def _verify_chain(self, session: Session) -> None:
if not verify_recovery_evidence_chain(session, self.operation_id):
raise RecoveryGuaranteeError(
@@ -244,6 +512,7 @@ def begin_durable_recovery_operation(
resource_type: str | None = None,
resource_id: str | None = None,
metadata: dict[str, Any] | None = None,
block_unresolved_resource: bool = False,
) -> DurableRecoveryStart:
if lease_ttl_seconds < 1:
raise ValueError("Recovery lease TTL must be at least one second")
@@ -271,6 +540,31 @@ def begin_durable_recovery_operation(
RecoveryOperation.idempotency_key == idempotency_key,
)
).scalar_one_or_none()
if block_unresolved_resource:
blocking = session.execute(
select(RecoveryOperation).where(
RecoveryOperation.installation_id == identity.installation_id,
RecoveryOperation.lease_resource_key == lease_resource_key,
RecoveryOperation.status.not_in(
(
RecoveryStatus.SUCCEEDED.value,
RecoveryStatus.REJECTED.value,
RecoveryStatus.FAILED.value,
RecoveryStatus.RECOVERED.value,
RecoveryStatus.MANUAL_INTERVENTION.value,
)
),
)
).scalars().first()
if blocking is not None and (
existing is None or blocking.id != existing.id
):
release_lease(session, claim)
session.commit()
raise RecoveryOperationStateConflict(
blocking.id,
blocking.status,
)
operation = plan_recovery_operation(
session,
installation_id=identity.installation_id,
@@ -339,6 +633,7 @@ def claim_durable_recovery_operation(
raise RecoveryGuaranteeError("Recovery operation was not found")
if candidate.status in {
RecoveryStatus.SUCCEEDED.value,
RecoveryStatus.REJECTED.value,
RecoveryStatus.FAILED.value,
RecoveryStatus.RECOVERED.value,
RecoveryStatus.MANUAL_INTERVENTION.value,
+127
View File
@@ -25,10 +25,23 @@ 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.information_governance import (
information_governance_maturity_issues,
)
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,
@@ -80,8 +93,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[
@@ -139,6 +154,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
)
@@ -148,6 +166,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:
@@ -255,6 +274,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()
@@ -263,6 +299,7 @@ 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
@@ -270,7 +307,69 @@ class PlatformRegistry:
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)
@@ -311,6 +410,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:
@@ -349,6 +454,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:
@@ -646,6 +757,10 @@ 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:
@@ -676,6 +791,13 @@ def _validate_architecture_declarations(manifest: ModuleManifest) -> None:
raise RegistryError(
f"Module {manifest.id!r} architecture declaration: {issue}"
)
for issue in information_governance_maturity_issues(
manifest.information_governance,
maturity=architecture.maturity if architecture is not None else None,
):
raise RegistryError(
f"Module {manifest.id!r} information-governance declaration: {issue}"
)
provider_ids: set[str] = set()
declared_capabilities = {
@@ -1013,6 +1135,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):
@@ -122,6 +122,26 @@ class RuntimeIdentity:
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
+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",
+182
View File
@@ -0,0 +1,182 @@
from __future__ import annotations
from contextvars import ContextVar, Token
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Literal
TemporalValidityMode = Literal["current", "at", "all"]
VALIDITY_MODES = frozenset({"current", "at", "all"})
VALIDITY_MODE_HEADER = "X-Govoplan-Validity-Mode"
VALID_AT_HEADER = "X-Govoplan-Valid-At"
RECORDED_AT_HEADER = "X-Govoplan-Recorded-At"
TEMPORAL_EVALUATED_AT_HEADER = "X-Govoplan-Temporal-Evaluated-At"
TEMPORAL_VARY_HEADERS = (
VALIDITY_MODE_HEADER,
VALID_AT_HEADER,
RECORDED_AT_HEADER,
)
class TemporalContextError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class TemporalDataContext:
"""Bitemporal read context.
Valid time answers when a fact applies. Recorded time answers which version
of that fact was known to the system. Authorization remains outside this
context and is always evaluated under the current security state.
"""
validity_mode: TemporalValidityMode = "current"
valid_at: datetime | None = None
recorded_at: datetime | None = None
evaluated_at: datetime = field(default_factory=lambda: datetime.now(UTC))
def __post_init__(self) -> None:
if self.validity_mode not in VALIDITY_MODES:
raise TemporalContextError(
f"Unsupported temporal validity mode: {self.validity_mode!r}."
)
for name in ("valid_at", "recorded_at", "evaluated_at"):
value = getattr(self, name)
if value is not None and value.tzinfo is None:
raise TemporalContextError(f"Temporal {name} must include a timezone.")
if self.validity_mode == "at" and self.valid_at is None:
raise TemporalContextError("Validity mode 'at' requires valid_at.")
if self.validity_mode != "at" and self.valid_at is not None:
raise TemporalContextError(
"valid_at is only permitted when validity mode is 'at'."
)
@property
def validity_instant(self) -> datetime | None:
if self.validity_mode == "all":
return None
if self.validity_mode == "at":
return self.valid_at
return self.evaluated_at
@property
def is_default(self) -> bool:
return self.validity_mode == "current" and self.recorded_at is None
def to_dict(self) -> dict[str, str | None]:
return {
"validity_mode": self.validity_mode,
"valid_at": _datetime_text(self.valid_at),
"recorded_at": _datetime_text(self.recorded_at),
"evaluated_at": _datetime_text(self.evaluated_at),
}
_temporal_context: ContextVar[TemporalDataContext | None] = ContextVar(
"govoplan_temporal_data_context",
default=None,
)
def parse_temporal_data_context(
*,
validity_mode: str | None = None,
valid_at: str | None = None,
recorded_at: str | None = None,
evaluated_at: datetime | None = None,
) -> TemporalDataContext:
clean_mode = (validity_mode or "current").strip().lower()
if clean_mode not in VALIDITY_MODES:
raise TemporalContextError(
"Temporal validity mode must be one of: current, at, all."
)
return TemporalDataContext(
validity_mode=clean_mode, # type: ignore[arg-type]
valid_at=_parse_datetime(valid_at, "valid_at"),
recorded_at=_parse_datetime(recorded_at, "recorded_at"),
evaluated_at=evaluated_at or datetime.now(UTC),
)
def current_temporal_data_context() -> TemporalDataContext:
return _temporal_context.get() or TemporalDataContext()
def bind_temporal_data_context(
context: TemporalDataContext,
) -> Token[TemporalDataContext | None]:
return _temporal_context.set(context)
def reset_temporal_data_context(token: Token[TemporalDataContext | None]) -> None:
_temporal_context.reset(token)
def temporal_revision_matches(
context: TemporalDataContext,
*,
valid_from: datetime | None = None,
valid_to: datetime | None = None,
revision_recorded_at: datetime | None = None,
superseded_at: datetime | None = None,
) -> bool:
cutoff = context.recorded_at
if cutoff is None:
if superseded_at is not None:
return False
else:
if revision_recorded_at is None or revision_recorded_at > cutoff:
return False
if superseded_at is not None and superseded_at <= cutoff:
return False
instant = context.validity_instant
if instant is None:
return True
return (valid_from is None or valid_from <= instant) and (
valid_to is None or valid_to > instant
)
def _parse_datetime(value: str | None, name: str) -> datetime | None:
clean = str(value or "").strip()
if not clean:
return None
if len(clean) > 64:
raise TemporalContextError(f"Temporal {name} is too long.")
normalized = f"{clean[:-1]}+00:00" if clean.endswith(("Z", "z")) else clean
try:
parsed = datetime.fromisoformat(normalized)
except ValueError as exc:
raise TemporalContextError(
f"Temporal {name} must be an ISO 8601 timestamp."
) from exc
if parsed.tzinfo is None:
raise TemporalContextError(f"Temporal {name} must include a timezone.")
return parsed.astimezone(UTC)
def _datetime_text(value: datetime | None) -> str | None:
if value is None:
return None
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
__all__ = [
"RECORDED_AT_HEADER",
"TEMPORAL_EVALUATED_AT_HEADER",
"TEMPORAL_VARY_HEADERS",
"VALIDITY_MODE_HEADER",
"VALID_AT_HEADER",
"TemporalContextError",
"TemporalDataContext",
"TemporalValidityMode",
"bind_temporal_data_context",
"current_temporal_data_context",
"parse_temporal_data_context",
"reset_temporal_data_context",
"temporal_revision_matches",
]
+196 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from datetime import UTC, datetime
import json
from typing import Literal, Protocol, runtime_checkable
@@ -15,6 +15,20 @@ 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."""
@@ -30,6 +44,119 @@ def voting_provider_capability(provider_id: str) -> str:
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
@@ -184,6 +311,8 @@ class ExternalVotingProvider(Protocol):
provider credentials must not cross this boundary.
"""
def assurance_declaration(self) -> VotingProviderAssuranceDeclaration: ...
def finalize_ballot(
self,
session: object,
@@ -283,6 +412,45 @@ class VotingBallotProvider(Protocol):
) -> 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:
@@ -330,6 +498,25 @@ def _reject_sensitive_evidence(value: object) -> None:
_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",
@@ -343,6 +530,11 @@ __all__ = [
"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",
@@ -350,7 +542,10 @@ __all__ = [
"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"]
+2
View File
@@ -127,6 +127,7 @@ class WorkflowRuntimeWorker(Protocol):
self,
session: object,
*,
tenant_id: str | None = None,
now: datetime | None = None,
limit: int = 50,
) -> Mapping[str, object]: ...
@@ -140,6 +141,7 @@ class WorkflowTriggerDispatcher(Protocol):
self,
session: object,
*,
tenant_id: str | None = None,
now: datetime | None = None,
limit: int = 50,
) -> Mapping[str, object]: ...
+2 -1
View File
@@ -32,6 +32,7 @@ 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
@@ -72,7 +73,7 @@ def bootstrap_dev_data(
) -> BootstrapResult:
tenant = session.query(Tenant).filter(Tenant.slug == tenant_slug).one_or_none()
if tenant is None:
tenant = Tenant(slug=tenant_slug, name="Default Tenant", default_locale="en", settings={})
tenant = Tenant(slug=tenant_slug, name="Default Tenant", default_locale="de", settings={})
session.add(tenant)
session.flush()
+70 -1
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,7 @@ 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
@@ -574,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)
@@ -619,7 +685,10 @@ def configured_migration_heads(
manifest_factories=manifest_factories,
migration_track=migration_track,
)
return tuple(sorted(ScriptDirectory.from_config(config).get_heads()))
scripts = ScriptDirectory.from_config(config)
return tuple(
sorted(revision.revision for revision in scripts.get_revisions("heads"))
)
def database_is_at_configured_heads(
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from typing import Any
from sqlalchemy import or_
from govoplan_core.core.temporal import (
TemporalContextError,
TemporalDataContext,
current_temporal_data_context,
)
def apply_temporal_revision_filter(
query: Any,
model: type[Any],
*,
context: TemporalDataContext | None = None,
valid_from: str | None = "valid_from",
valid_to: str | None = "valid_to",
recorded_at: str | None = "recorded_at",
superseded_at: str | None = "superseded_at",
) -> Any:
"""Apply latest/as-recorded and valid-time clauses to a revision query."""
resolved = context or current_temporal_data_context()
clauses: list[Any] = []
superseded_column = _optional_column(model, superseded_at)
recorded_column = _optional_column(model, recorded_at)
if resolved.recorded_at is None:
if superseded_column is not None:
clauses.append(superseded_column.is_(None))
else:
if recorded_column is None or superseded_column is None:
raise TemporalContextError(
f"{model.__name__} does not expose recorded/superseded revision time."
)
clauses.extend(
(
recorded_column <= resolved.recorded_at,
or_(
superseded_column.is_(None),
superseded_column > resolved.recorded_at,
),
)
)
instant = resolved.validity_instant
if instant is not None:
valid_from_column = _optional_column(model, valid_from)
valid_to_column = _optional_column(model, valid_to)
if valid_from_column is not None:
clauses.append(
or_(valid_from_column.is_(None), valid_from_column <= instant)
)
if valid_to_column is not None:
clauses.append(or_(valid_to_column.is_(None), valid_to_column > instant))
return query.filter(*clauses) if clauses else query
def _optional_column(model: type[Any], name: str | None) -> Any | None:
if name is None:
return None
column = getattr(model, name, None)
if column is None:
raise TemporalContextError(
f"{model.__name__} has no temporal column named {name!r}."
)
return column
__all__ = ["apply_temporal_revision_filter"]
+14 -7
View File
@@ -4,10 +4,12 @@ import re
from typing import Any, Iterable
I18N_SETTINGS_KEY = "i18n"
REFERENCE_LANGUAGE_CODE = "de"
SOURCE_LANGUAGE_CODE = "en"
DEFAULT_LANGUAGE_PACKAGES: tuple[dict[str, str], ...] = (
{"code": "en", "label": "English", "native_label": "English"},
{"code": "de", "label": "German", "native_label": "Deutsch"},
{"code": "en", "label": "English", "native_label": "English"},
)
@@ -66,7 +68,7 @@ def normalize_enabled_language_codes(
available_languages: Iterable[dict[str, Any]],
*,
default_locale: object = None,
fallback_codes: Iterable[str] = ("en", "de"),
fallback_codes: Iterable[str] = (REFERENCE_LANGUAGE_CODE, SOURCE_LANGUAGE_CODE),
) -> list[str]:
available_codes = [normalize_language_code(item.get("code")) for item in available_languages if isinstance(item, dict)]
available = {code for code in available_codes if code}
@@ -88,8 +90,10 @@ def normalize_enabled_language_codes(
if enabled:
return enabled
if "en" in available:
return ["en"]
if REFERENCE_LANGUAGE_CODE in available:
return [REFERENCE_LANGUAGE_CODE]
if SOURCE_LANGUAGE_CODE in available:
return [SOURCE_LANGUAGE_CODE]
return available_codes[:1]
@@ -126,7 +130,7 @@ def preferred_language_code(user_settings: dict[str, Any] | None, allowed_codes:
default = resolve_language_code(default_locale, enabled)
if default:
return default
return enabled[0] if enabled else "en"
return enabled[0] if enabled else REFERENCE_LANGUAGE_CODE
def update_i18n_settings(settings: dict[str, Any] | None, **values: Any) -> dict[str, Any]:
@@ -141,10 +145,13 @@ def system_i18n_payload(settings_item: Any | None) -> dict[str, object]:
settings = getattr(settings_item, "settings", None)
packages = system_language_packages(settings)
available_codes = [item["code"] for item in packages]
default_language = resolve_language_code(getattr(settings_item, "default_locale", None), available_codes) or "en"
default_language = (
resolve_language_code(getattr(settings_item, "default_locale", None), available_codes)
or REFERENCE_LANGUAGE_CODE
)
enabled = system_enabled_language_codes(settings, default_locale=default_language)
if default_language not in enabled:
default_language = enabled[0] if enabled else "en"
default_language = enabled[0] if enabled else REFERENCE_LANGUAGE_CODE
return {
"available_languages": packages,
"enabled_languages": enabled,
@@ -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",
]
@@ -7,7 +7,15 @@ from fastapi import Request
from starlette.responses import Response
JSON_CACHE_CONTROL = "private, no-cache"
JSON_ETAG_VARY_HEADERS = ("Authorization", "Cookie", "X-API-Key", "Accept-Language")
JSON_ETAG_VARY_HEADERS = (
"Authorization",
"Cookie",
"X-API-Key",
"Accept-Language",
"X-Govoplan-Validity-Mode",
"X-Govoplan-Valid-At",
"X-Govoplan-Recorded-At",
)
async def conditional_json_get_middleware(
+16 -3
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from importlib.metadata import PackageNotFoundError, version
from fastapi import Depends, FastAPI
from fastapi import HTTPException, status
@@ -12,10 +13,21 @@ 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, runtime_identity
from govoplan_core.core.runtime_coordination import (
RuntimeIdentity,
bind_process_runtime_identity,
runtime_identity,
)
from govoplan_core.settings import Settings, settings
def _core_distribution_version() -> str:
try:
return version("govoplan-core")
except PackageNotFoundError:
return "development"
def _dev_bootstrap_needs_create_all(database_url: str) -> bool:
try:
return make_url(database_url).get_backend_name() == "sqlite"
@@ -57,7 +69,7 @@ async def lifespan(app: FastAPI):
)
runtime_agent = RuntimeNodeAgent(
settings=settings,
software_version=app.version,
software_version=_core_distribution_version(),
module_ids=module_ids,
metadata={"process": "api"},
identity=configured_identity
@@ -89,9 +101,10 @@ def register_health_details(
):
app.state.govoplan_runtime_identity = runtime_identity(
active_settings,
software_version=app.version,
software_version=_core_distribution_version(),
module_ids=module_ids,
)
bind_process_runtime_identity(app.state.govoplan_runtime_identity)
@app.get("/health/details")
def health_details(
+2
View File
@@ -17,6 +17,7 @@ from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.query_metrics import collect_query_metrics
from govoplan_core.server.conditional_requests import conditional_json_get_middleware
from govoplan_core.server.request_limits import RequestBodyLimitMiddleware
from govoplan_core.server.temporal import temporal_data_context_middleware
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
logger = logging.getLogger("govoplan.request")
@@ -169,6 +170,7 @@ def create_govoplan_app(
return response
app.middleware("http")(conditional_json_get_middleware)
app.middleware("http")(temporal_data_context_middleware)
origins = [item.strip() for item in cors_origins if item.strip()]
if origins:
+146 -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,
@@ -87,6 +139,64 @@ def _frontend_view_surfaces(manifest: ModuleManifest) -> list[dict[str, object]]
]
def _documentation_help_contexts(manifest: ModuleManifest) -> list[dict[str, object]]:
contexts: list[dict[str, object]] = []
seen: set[str] = set()
def append_context(context_id: str, topic) -> None:
if not context_id or context_id in seen:
return
seen.add(context_id)
contexts.append(
{
"id": context_id,
"topic_id": topic.id,
"title": topic.title,
"documentation_types": list(topic.documentation_types),
}
)
for topic in manifest.documentation:
raw_contexts = topic.metadata.get("help_contexts", ())
if isinstance(raw_contexts, str) or not isinstance(raw_contexts, (list, tuple, set)):
continue
for raw_context in raw_contexts:
context_id = str(raw_context).strip()
append_context(context_id, topic)
frontend = manifest.frontend
if frontend is None or not manifest.documentation:
return contexts
ordered_topics = sorted(manifest.documentation, key=lambda item: (item.order, item.id))
def baseline_topic(documentation_type: str):
return next(
(
topic
for topic in ordered_topics
if documentation_type in topic.documentation_types
),
ordered_topics[0],
)
user_topic = baseline_topic("user")
admin_topic = baseline_topic("admin")
for route in frontend.routes:
if route.surface_id:
append_context(route.surface_id, user_topic)
for route in frontend.settings_routes:
if route.surface_id:
append_context(route.surface_id, admin_topic)
for item in frontend.nav_items:
if item.surface_id:
append_context(item.surface_id, user_topic)
for surface in frontend.view_surfaces:
topic = admin_topic if ".admin." in surface.id else user_topic
append_context(surface.id, topic)
return contexts
def _frontend_payload(manifest: ModuleManifest) -> dict[str, object] | None:
frontend = manifest.frontend
if frontend is None:
@@ -156,9 +266,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": [
{
@@ -173,18 +288,43 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
if manifest.architecture is not None
else None
),
"information_governance": manifest.information_governance.to_dict(),
"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"
},
"help_contexts": _documentation_help_contexts(manifest),
"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)
@@ -194,6 +334,7 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
"id": manifest.id,
"name": manifest.name,
"version": manifest.version,
"help_contexts": _documentation_help_contexts(manifest),
"frontend": _public_frontend_payload(manifest.frontend),
}
for manifest in registry.manifests()
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from fastapi import Request, Response
from fastapi.responses import JSONResponse
from govoplan_core.core.temporal import (
RECORDED_AT_HEADER,
TEMPORAL_EVALUATED_AT_HEADER,
TEMPORAL_VARY_HEADERS,
VALIDITY_MODE_HEADER,
VALID_AT_HEADER,
TemporalContextError,
TemporalDataContext,
bind_temporal_data_context,
current_temporal_data_context,
parse_temporal_data_context,
reset_temporal_data_context,
)
async def temporal_data_context_middleware(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
try:
context = parse_temporal_data_context(
validity_mode=request.headers.get(VALIDITY_MODE_HEADER),
valid_at=request.headers.get(VALID_AT_HEADER),
recorded_at=request.headers.get(RECORDED_AT_HEADER),
evaluated_at=datetime.now(UTC),
)
except TemporalContextError as exc:
return JSONResponse(status_code=400, content={"detail": str(exc)})
request.state.govoplan_temporal_data_context = context
token = bind_temporal_data_context(context)
try:
response = await call_next(request)
finally:
reset_temporal_data_context(token)
response.headers[VALIDITY_MODE_HEADER] = context.validity_mode
response.headers[TEMPORAL_EVALUATED_AT_HEADER] = _timestamp(
context.evaluated_at
)
if context.valid_at is not None:
response.headers[VALID_AT_HEADER] = _timestamp(context.valid_at)
if context.recorded_at is not None:
response.headers[RECORDED_AT_HEADER] = _timestamp(context.recorded_at)
_merge_vary(response, TEMPORAL_VARY_HEADERS)
return response
def get_temporal_data_context(request: Request) -> TemporalDataContext:
context = getattr(request.state, "govoplan_temporal_data_context", None)
return context if isinstance(context, TemporalDataContext) else current_temporal_data_context()
def _merge_vary(response: Response, names: tuple[str, ...]) -> None:
current = {
item.strip().lower(): item.strip()
for item in response.headers.get("Vary", "").split(",")
if item.strip()
}
for name in names:
current.setdefault(name.lower(), name)
response.headers["Vary"] = ", ".join(current.values())
def _timestamp(value: datetime) -> str:
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
__all__ = ["get_temporal_data_context", "temporal_data_context_middleware"]
+25
View File
@@ -198,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,
@@ -259,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")
+1 -1
View File
@@ -33,7 +33,7 @@ class Tenant:
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False)
default_locale: Mapped[str] = mapped_column(String(20), default="de", nullable=False)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
allow_custom_groups: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
allow_custom_roles: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
+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}
+13
View File
@@ -4377,6 +4377,7 @@ class ApiSmokeTests(unittest.TestCase):
with SessionLocal() as session:
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
job_id = job.id
generated_eml = self._stored_campaign_eml(job)
sent = self.client.post(
@@ -4398,6 +4399,18 @@ class ApiSmokeTests(unittest.TestCase):
self.assertTrue(raw_filename)
captured_eml = (_TEST_ROOT / "mock-mailbox" / "messages" / str(raw_filename)).read_bytes()
self.assertEqual(captured_eml, generated_eml)
with SessionLocal() as session:
operation = (
session.query(RecoveryOperation)
.filter(
RecoveryOperation.operation_type
== "external-channel-delivery",
RecoveryOperation.resource_id == job_id,
)
.one()
)
self.assertEqual(operation.status, RecoveryStatus.SUCCEEDED.value)
self.assertTrue(verify_recovery_evidence_chain(session, operation.id))
def test_send_now_rejects_modified_generated_eml_before_delivery(self) -> None:
headers, _ = self._login()
+24
View File
@@ -181,6 +181,14 @@ class AutomationContractTests(unittest.TestCase):
self.assertTrue(preview.allowed)
self.assertEqual("compensatable", preview.reversibility)
self.assertEqual("forward_recovery", provider.action.recovery_mode)
self.assertEqual(
(
"verify the provider result and every announced effect "
"before continuation",
),
provider.action.recovery_verification,
)
self.assertEqual("completed", result.state)
self.assertEqual(
"postbox-message:1",
@@ -234,6 +242,22 @@ class AutomationContractTests(unittest.TestCase):
description="Test effect",
contract_version="2",
)
with self.assertRaisesRegex(ValueError, "recovery verification"):
ActionDefinition(
action_key="invalid.recovery",
owner_module="test",
description="Invalid recovery declaration",
input_schema_ref="schema:invalid.recovery@1",
recovery_verification=(),
)
with self.assertRaisesRegex(ValueError, "recovery mode"):
ActionDefinition(
action_key="invalid.recovery-mode",
owner_module="test",
description="Invalid recovery mode",
input_schema_ref="schema:invalid.recovery-mode@1",
recovery_mode="best_effort", # type: ignore[arg-type]
)
if __name__ == "__main__":
+5
View File
@@ -4,6 +4,7 @@ import unittest
from unittest.mock import MagicMock, patch
from govoplan_core.celery_app import celery, dispatch_calendar_outbox
from tests.worker_test_support import allowed_worker_admissions
class CalendarOutboxWorkerTests(unittest.TestCase):
@@ -22,6 +23,10 @@ class CalendarOutboxWorkerTests(unittest.TestCase):
with (
patch("govoplan_core.celery_app._calendar_outbox", return_value=provider),
patch(
"govoplan_core.celery_app._worker_admissions",
side_effect=allowed_worker_admissions,
),
patch("govoplan_core.db.session.get_database", return_value=database),
):
result = dispatch_calendar_outbox.run("tenant-1", 25)
+18
View File
@@ -48,6 +48,10 @@ class ConditionalRequestTests(unittest.TestCase):
self.assertIn("private", first.headers.get("cache-control", ""))
self.assertIn("no-cache", first.headers.get("cache-control", ""))
self.assertIn("authorization", first.headers.get("vary", "").lower())
self.assertIn(
"x-govoplan-validity-mode",
first.headers.get("vary", "").lower(),
)
self.assertEqual("request-1", first.headers["X-Correlation-ID"])
second = client.get("/json", headers={"If-None-Match": etag or "", "X-Request-ID": "request-2"})
@@ -56,6 +60,20 @@ class ConditionalRequestTests(unittest.TestCase):
self.assertEqual(etag, second.headers.get("etag"))
self.assertEqual("request-2", second.headers["X-Correlation-ID"])
historical = client.get(
"/json",
headers={
"X-Govoplan-Validity-Mode": "at",
"X-Govoplan-Valid-At": "2025-02-03T10:30:00Z",
},
)
self.assertEqual(200, historical.status_code, historical.text)
self.assertEqual("at", historical.headers["X-Govoplan-Validity-Mode"])
self.assertIn(
"x-govoplan-valid-at",
historical.headers.get("vary", "").lower(),
)
def test_changed_json_body_does_not_match_previous_etag(self) -> None:
with self._client() as client:
first = client.get("/json?value=alpha")
+65
View File
@@ -1,11 +1,13 @@
from __future__ import annotations
import logging
import os
import tempfile
import unittest
from pathlib import Path
from alembic import command
from alembic.config import Config
from alembic.runtime.migration import MigrationContext
from alembic.script import ScriptDirectory
from sqlalchemy import create_engine, inspect, text
@@ -16,6 +18,7 @@ from govoplan_core.db.migrations import (
migrate_database,
reconcile_change_sequence_retention_floor_drift,
reconcile_namespace_table_drift,
validate_unique_migration_revisions,
)
@@ -40,6 +43,25 @@ def database_migration_heads(connection) -> set[str]:
class DatabaseMigrationTests(unittest.TestCase):
def test_duplicate_module_revision_ids_are_rejected_with_file_provenance(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-duplicate-revision-test-") as directory:
root = Path(directory)
first = root / "first"
second = root / "second"
first.mkdir()
second.mkdir()
(first / "first.py").write_text('revision = "duplicate123"\n', encoding="utf-8")
(second / "second.py").write_text('revision: str = "duplicate123"\n', encoding="utf-8")
config = Config()
config.set_main_option("version_locations", os.pathsep.join((str(first), str(second))))
with self.assertRaisesRegex(ValueError, "duplicate123") as raised:
validate_unique_migration_revisions(config)
message = str(raised.exception)
self.assertIn("first.py", message)
self.assertIn("second.py", message)
def test_migration_logging_keeps_application_loggers_enabled(self) -> None:
logger = logging.getLogger("govoplan.request")
previous_disabled = logger.disabled
@@ -162,6 +184,12 @@ class DatabaseMigrationTests(unittest.TestCase):
system_settings_count = connection.execute(
text("SELECT COUNT(*) FROM core_system_settings WHERE id = 'global'"),
).scalar_one()
default_locale = connection.execute(
text(
"SELECT default_locale FROM core_system_settings "
"WHERE id = 'global'"
),
).scalar_one()
current = database_migration_heads(connection)
self.assertEqual(
@@ -182,6 +210,7 @@ class DatabaseMigrationTests(unittest.TestCase):
self.assertIn("file_assets", tables)
self.assertIn("mail_server_profiles", tables)
self.assertEqual(system_settings_count, 1)
self.assertEqual(default_locale, "de")
self.assertEqual(
role_flags,
{"system_owner": True, "system_admin": False, "system_auditor": False},
@@ -189,6 +218,42 @@ class DatabaseMigrationTests(unittest.TestCase):
finally:
engine.dispose()
def test_german_reference_migration_preserves_explicit_english(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-locale-migration-test-") as directory:
database = Path(directory) / "locale.db"
url = f"sqlite:///{database}"
config = alembic_config(database_url=url, enabled_modules=())
command.upgrade(config, "f25c9d3e7a01")
engine = create_engine(url)
try:
with engine.begin() as connection:
connection.execute(
text(
"UPDATE core_system_settings "
"SET default_locale = 'en', "
"updated_at = '2040-01-01 00:00:00' "
"WHERE id = 'global'"
)
)
finally:
engine.dispose()
command.upgrade(config, "heads")
engine = create_engine(url)
try:
with engine.connect() as connection:
default_locale = connection.execute(
text(
"SELECT default_locale FROM core_system_settings "
"WHERE id = 'global'"
)
).scalar_one()
self.assertEqual(default_locale, "en")
finally:
engine.dispose()
def test_default_module_baselines_apply_to_fresh_database(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-default-baseline-test-") as directory:
database = Path(directory) / "default.db"
+15 -1
View File
@@ -8,6 +8,7 @@ from govoplan_core.celery_app import (
dispatch_dataflow_runs,
purge_dataflow_runs,
)
from tests.worker_test_support import allowed_worker_admissions
class DataflowRunWorkerTests(unittest.TestCase):
@@ -30,11 +31,16 @@ class DataflowRunWorkerTests(unittest.TestCase):
"govoplan_core.db.session.get_database",
return_value=database,
),
patch(
"govoplan_core.celery_app._worker_admissions",
side_effect=allowed_worker_admissions,
),
):
result = dispatch_dataflow_runs.run(7)
provider.dispatch_pending.assert_called_once_with(
session,
tenant_id="tenant-1",
limit=7,
worker_id=ANY,
)
@@ -57,10 +63,18 @@ class DataflowRunWorkerTests(unittest.TestCase):
"govoplan_core.db.session.get_database",
return_value=database,
),
patch(
"govoplan_core.celery_app._worker_admissions",
side_effect=allowed_worker_admissions,
),
):
result = purge_dataflow_runs.run(25)
provider.purge_expired.assert_called_once_with(session, limit=25)
provider.purge_expired.assert_called_once_with(
session,
tenant_id="tenant-1",
limit=25,
)
session.commit.assert_called_once_with()
self.assertEqual(2, result["purged"])
+10 -1
View File
@@ -4,6 +4,7 @@ import unittest
from unittest.mock import MagicMock, patch
from govoplan_core.celery_app import celery, dispatch_dataflow_triggers
from tests.worker_test_support import allowed_worker_admissions
class DataflowTriggerWorkerTests(unittest.TestCase):
@@ -30,10 +31,18 @@ class DataflowTriggerWorkerTests(unittest.TestCase):
"govoplan_core.db.session.get_database",
return_value=database,
),
patch(
"govoplan_core.celery_app._worker_admissions",
side_effect=allowed_worker_admissions,
),
):
result = dispatch_dataflow_triggers.run(25)
provider.dispatch_due.assert_called_once_with(session, limit=25)
provider.dispatch_due.assert_called_once_with(
session,
tenant_id="tenant-1",
limit=25,
)
session.commit.assert_called_once_with()
self.assertEqual(result["succeeded"], 1)
+386
View File
@@ -0,0 +1,386 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import json
import os
from pathlib import Path
import stat
from types import SimpleNamespace
from unittest.mock import patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from govoplan_core.commands.first_admin import _write_private_json
from govoplan_core.core.access import (
FirstAdminProvisioner,
FirstAdminProvisioningError,
FirstSystemAdministratorRef,
)
from govoplan_core.core.first_admin import (
FirstAdminEnrollment,
FirstAdminEnrollmentConflict,
FirstAdminEnrollmentCredentialError,
FirstAdminEnrollmentEvent,
FirstAdminEnrollmentState,
FirstAdminEnrollmentUnavailable,
consume_first_admin_credential,
first_admin_enrollment_status,
issue_first_admin_credential,
)
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.base import Base
from govoplan_core.db.session import get_session
from govoplan_core.server.bootstrap import create_bootstrap_router
from govoplan_core.tenancy.scope import scope_registry
class _Provisioner(FirstAdminProvisioner):
def __init__(self, *, administrator_exists: bool = False, fail_create: bool = False) -> None:
self.administrator_exists = administrator_exists
self.fail_create = fail_create
self.create_count = 0
def has_durable_system_administrator(self, session: object) -> bool:
del session
return self.administrator_exists
def create_first_system_administrator(
self,
session: object,
*,
tenant: object,
email: str,
display_name: str | None,
password: str,
) -> FirstSystemAdministratorRef:
del session, password
if self.fail_create:
raise FirstAdminProvisioningError("simulated authority failure")
self.create_count += 1
self.administrator_exists = True
return FirstSystemAdministratorRef(
account_id="account-1",
email=email,
display_name=display_name,
membership_id="membership-1",
tenant_id=str(getattr(tenant, "id")),
)
@pytest.fixture
def session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
scope_registry.metadata.create_all(engine)
Base.metadata.create_all(
engine,
tables=[
FirstAdminEnrollment.__table__,
FirstAdminEnrollmentEvent.__table__,
],
)
with Session(engine, expire_on_commit=False) as item:
yield item
engine.dispose()
def test_single_use_enrollment_creates_authority_and_replays_idempotently(
session: Session,
) -> None:
provisioner = _Provisioner()
now = datetime(2026, 8, 4, 12, tzinfo=timezone.utc)
with patch("govoplan_core.core.first_admin.audit_event"):
issued = issue_first_admin_credential(
session,
installation_id="installation-1",
provisioner=provisioner,
ttl_seconds=900,
reason="initial installation",
now=now,
)
session.commit()
result = consume_first_admin_credential(
session,
installation_id="installation-1",
provisioner=provisioner,
secret=issued.secret,
email="owner@example.test",
display_name="System Owner",
password="a-production-password",
tenant_slug="default",
tenant_name="Default Tenant",
now=now + timedelta(minutes=1),
)
session.commit()
replay = consume_first_admin_credential(
session,
installation_id="installation-1",
provisioner=provisioner,
secret=issued.secret,
email="owner@example.test",
display_name="System Owner",
password="a-production-password",
tenant_slug="default",
tenant_name="Default Tenant",
now=now + timedelta(minutes=2),
)
assert not result.replayed
assert replay.replayed
assert replay.administrator.account_id == "account-1"
assert provisioner.create_count == 1
enrollment = session.get(FirstAdminEnrollment, "installation-1")
assert enrollment is not None
assert enrollment.state == FirstAdminEnrollmentState.CONSUMED.value
assert enrollment.consumed_account_id == "account-1"
assert enrollment.token_sha256 != issued.secret
evidence = session.scalars(
select(FirstAdminEnrollmentEvent).order_by(FirstAdminEnrollmentEvent.sequence)
).all()
assert [item.event_type for item in evidence] == [
"credential_issued",
"administrator_created",
]
assert evidence[1].previous_sha256 == evidence[0].event_sha256
assert issued.secret not in json.dumps([item.evidence for item in evidence])
def test_consumed_credential_rejects_a_different_request(session: Session) -> None:
provisioner = _Provisioner()
with patch("govoplan_core.core.first_admin.audit_event"):
issued = issue_first_admin_credential(
session,
installation_id="installation-1",
provisioner=provisioner,
ttl_seconds=900,
reason="initial installation",
)
consume_first_admin_credential(
session,
installation_id="installation-1",
provisioner=provisioner,
secret=issued.secret,
email="owner@example.test",
display_name=None,
password="a-production-password",
tenant_slug="default",
tenant_name="Default Tenant",
)
session.commit()
with pytest.raises(FirstAdminEnrollmentCredentialError, match="already been used"):
consume_first_admin_credential(
session,
installation_id="installation-1",
provisioner=provisioner,
secret=issued.secret,
email="other@example.test",
display_name=None,
password="a-production-password",
tenant_slug="default",
tenant_name="Default Tenant",
)
def test_recovery_rotates_lost_or_expired_material(session: Session) -> None:
provisioner = _Provisioner()
now = datetime(2026, 8, 4, 12, tzinfo=timezone.utc)
with patch("govoplan_core.core.first_admin.audit_event"):
first = issue_first_admin_credential(
session,
installation_id="installation-1",
provisioner=provisioner,
ttl_seconds=60,
reason="initial installation",
now=now,
)
with pytest.raises(FirstAdminEnrollmentConflict, match="already exists"):
issue_first_admin_credential(
session,
installation_id="installation-1",
provisioner=provisioner,
ttl_seconds=60,
reason="duplicate issue",
now=now + timedelta(seconds=30),
)
replacement = issue_first_admin_credential(
session,
installation_id="installation-1",
provisioner=provisioner,
ttl_seconds=300,
reason="lost credential",
replace_active=True,
now=now + timedelta(seconds=30),
)
session.commit()
with pytest.raises(FirstAdminEnrollmentCredentialError, match="invalid"):
consume_first_admin_credential(
session,
installation_id="installation-1",
provisioner=provisioner,
secret=first.secret,
email="owner@example.test",
display_name=None,
password="a-production-password",
tenant_slug="default",
tenant_name="Default Tenant",
now=now + timedelta(seconds=40),
)
assert replacement.generation == 2
def test_failed_authority_creation_rolls_back_without_consuming_secret(
session: Session,
) -> None:
provisioner = _Provisioner(fail_create=True)
with patch("govoplan_core.core.first_admin.audit_event"):
issued = issue_first_admin_credential(
session,
installation_id="installation-1",
provisioner=provisioner,
ttl_seconds=900,
reason="initial installation",
)
session.commit()
with pytest.raises(FirstAdminEnrollmentConflict, match="simulated"):
consume_first_admin_credential(
session,
installation_id="installation-1",
provisioner=provisioner,
secret=issued.secret,
email="owner@example.test",
display_name=None,
password="a-production-password",
tenant_slug="default",
tenant_name="Default Tenant",
)
session.rollback()
enrollment = session.get(FirstAdminEnrollment, "installation-1")
assert enrollment is not None
assert enrollment.state == FirstAdminEnrollmentState.ACTIVE.value
assert enrollment.consumed_account_id is None
def test_enrollment_is_unavailable_after_durable_admin_exists(session: Session) -> None:
provisioner = _Provisioner(administrator_exists=True)
with patch("govoplan_core.core.first_admin.audit_event"):
with pytest.raises(FirstAdminEnrollmentUnavailable):
issue_first_admin_credential(
session,
installation_id="installation-1",
provisioner=provisioner,
ttl_seconds=900,
reason="must fail",
)
readiness = first_admin_enrollment_status(
session,
installation_id="installation-1",
provisioner=provisioner,
)
assert not readiness.enrollment_required
assert readiness.state == "completed"
def test_operator_artifact_is_owner_readable_only(tmp_path: Path) -> None:
output = tmp_path / "bootstrap" / "first-admin.json"
_write_private_json(output, {"enrollment_token": "never-print-this"})
assert stat.S_IMODE(output.stat().st_mode) == 0o600
assert output.read_text(encoding="utf-8").endswith("\n")
assert os.geteuid() == output.stat().st_uid
def test_public_api_is_limited_to_readiness_and_single_enrollment(
session: Session,
) -> None:
provisioner = _Provisioner()
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="access",
name="Access",
version="test",
capability_factories={
"access.firstAdminProvisioner": lambda _context: provisioner,
},
)
)
settings = SimpleNamespace(installation_id="installation-1")
registry.configure_capability_context(
ModuleContext(registry=registry, settings=settings)
)
app = FastAPI()
app.state.govoplan_registry = registry
app.include_router(create_bootstrap_router(settings), prefix="/api/v1")
def _session_override():
yield session
app.dependency_overrides[get_session] = _session_override
with patch("govoplan_core.core.first_admin.audit_event"):
issued = issue_first_admin_credential(
session,
installation_id="installation-1",
provisioner=provisioner,
ttl_seconds=900,
reason="API test",
)
session.commit()
with TestClient(app) as client:
ready = client.get("/api/v1/bootstrap/status")
enrolled = client.post(
"/api/v1/bootstrap/first-admin",
headers={"X-GovOPlaN-Enrollment-Token": issued.secret},
json={
"email": "owner@example.test",
"display_name": "System Owner",
"password": "a-production-password",
"tenant_slug": "default",
"tenant_name": "Default Tenant",
},
)
completed = client.get("/api/v1/bootstrap/status")
assert ready.status_code == 200
assert ready.json()["credential_active"] is True
assert enrolled.status_code == 201
assert enrolled.json()["bootstrap_retired"] is True
assert completed.json()["state"] == "completed"
def test_readiness_reports_missing_access_without_exposing_an_enrollment_api(
session: Session,
) -> None:
registry = PlatformRegistry()
settings = SimpleNamespace(installation_id="installation-1")
app = FastAPI()
app.state.govoplan_registry = registry
app.include_router(create_bootstrap_router(settings), prefix="/api/v1")
def _session_override():
yield session
app.dependency_overrides[get_session] = _session_override
with TestClient(app) as client:
ready = client.get("/api/v1/bootstrap/status")
rejected = client.post(
"/api/v1/bootstrap/first-admin",
headers={"X-GovOPlaN-Enrollment-Token": "x" * 48},
json={
"email": "owner@example.test",
"password": "a-production-password",
},
)
assert ready.json()["state"] == "not_ready"
assert ready.json()["readiness"]["access_capability"] is False
assert rejected.status_code == 503
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
import unittest
from govoplan_core.i18n import (
REFERENCE_LANGUAGE_CODE,
normalize_enabled_language_codes,
normalize_language_packages,
preferred_language_code,
system_i18n_payload,
)
class I18nReferenceLanguageTests(unittest.TestCase):
def test_german_is_the_new_installation_reference_default(self) -> None:
packages = normalize_language_packages()
self.assertEqual("de", REFERENCE_LANGUAGE_CODE)
self.assertEqual(["de", "en"], [item["code"] for item in packages])
self.assertEqual(
["de", "en"],
normalize_enabled_language_codes(None, packages),
)
self.assertEqual("de", system_i18n_payload(None)["default_language"])
self.assertEqual("de", preferred_language_code(None, ()))
if __name__ == "__main__":
unittest.main()
@@ -11,6 +11,7 @@ from govoplan_core.core.idm import (
)
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
from tests.worker_test_support import allowed_worker_admissions
class _Lifecycle:
@@ -70,6 +71,10 @@ class IdmAssignmentLifecycleWorkerTests(unittest.TestCase):
"govoplan_core.db.session.get_database",
return_value=database,
),
patch(
"govoplan_core.celery_app._worker_admissions",
side_effect=allowed_worker_admissions,
),
):
result = expire_idm_assignments.run("tenant-1", 25)
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import unittest
from govoplan_core.core.information_governance import (
InformationGovernanceDeclarationError,
InformationGovernanceDimension,
ModuleInformationGovernance,
information_governance_from_mapping,
information_governance_maturity_issues,
)
class InformationGovernanceTests(unittest.TestCase):
def test_default_applies_contract_without_claiming_enforcement(self) -> None:
declaration = ModuleInformationGovernance()
self.assertTrue(declaration.current_authorization_for_historical_reads)
self.assertEqual(
{"contract_only"},
{item.adoption for item in declaration.dimensions.values()},
)
self.assertEqual("1", declaration.to_dict()["contract_version"])
def test_enforced_adoption_requires_evidence(self) -> None:
with self.assertRaisesRegex(
InformationGovernanceDeclarationError,
"requires evidence",
):
InformationGovernanceDimension(adoption="enforced")
with self.assertRaisesRegex(
InformationGovernanceDeclarationError,
"covered object types",
):
InformationGovernanceDimension(
adoption="enforced",
evidence=("tests/test_information_governance.py",),
)
def test_catalog_shape_round_trips(self) -> None:
declaration = ModuleInformationGovernance()
self.assertEqual(
declaration,
information_governance_from_mapping(declaration.to_dict()),
)
def test_reference_ready_rejects_unproved_dimensions(self) -> None:
issues = information_governance_maturity_issues(
ModuleInformationGovernance(),
maturity="reference_ready",
)
self.assertEqual(1, len(issues))
self.assertIn("temporal_browsing", issues[0])
self.assertIn("purpose_aware_access", issues[0])
self.assertEqual(
(),
information_governance_maturity_issues(
ModuleInformationGovernance(),
maturity="vertical_slice",
),
)
if __name__ == "__main__":
unittest.main()
+10
View File
@@ -10,6 +10,7 @@ from govoplan_core.celery_app import (
dispatch_mail_outbox,
purge_mail_outbox,
)
from tests.worker_test_support import allowed_worker_admissions
class _Provider:
@@ -35,6 +36,10 @@ class MailDeliveryWorkerTests(unittest.TestCase):
"govoplan_core.celery_app._mail_delivery_outbox",
return_value=_Provider(),
),
patch(
"govoplan_core.celery_app._worker_admissions",
side_effect=allowed_worker_admissions,
),
):
result = dispatch_mail_outbox.run("tenant-1", 7)
@@ -56,10 +61,15 @@ class MailDeliveryWorkerTests(unittest.TestCase):
"govoplan_core.celery_app._mail_delivery_outbox",
return_value=_Provider(),
),
patch(
"govoplan_core.celery_app._worker_admissions",
side_effect=allowed_worker_admissions,
),
):
result = purge_mail_outbox.run(19)
self.assertIs(result["session"], session)
self.assertEqual(result["tenant_id"], "tenant-1")
self.assertEqual(result["limit"], 19)
def test_worker_routes_and_schedules_are_declared(self) -> None:
+505
View File
@@ -0,0 +1,505 @@
from __future__ import annotations
import unittest
from pathlib import Path
import tempfile
from types import SimpleNamespace
from unittest.mock import patch
from fastapi import APIRouter, Depends, FastAPI
from fastapi.testclient import TestClient
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.celery_app import _run_tenant_worker_batches
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.lifecycle import require_module_active
from govoplan_core.core.module_entitlements import (
ModuleEntitlementConflict,
ModuleEntitlementError,
TenantModuleEntitlementResolver,
TenantModuleOperatorActionRequired,
TenantModuleUnavailable,
tenant_module_entitlement_state,
update_system_tenant_module_policy,
update_tenant_module_selection,
)
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.session import configure_database, get_database
from govoplan_core.server.platform import create_platform_router
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
class TenantModuleEntitlementTests(unittest.TestCase):
def setUp(self) -> None:
self.manifests = {
"access": ModuleManifest(id="access", name="Access", version="test"),
"admin": ModuleManifest(
id="admin",
name="Admin",
version="test",
dependencies=("access",),
),
"files": ModuleManifest(
id="files",
name="Files",
version="test",
dependencies=("access",),
),
"campaigns": ModuleManifest(
id="campaigns",
name="Campaigns",
version="test",
dependencies=("access", "files"),
),
"encryption": ModuleManifest(
id="encryption",
name="Encryption",
version="test",
),
}
def test_unconfigured_tenant_preserves_current_module_visibility(self) -> None:
state = tenant_module_entitlement_state({}, self.manifests)
self.assertFalse(state.configured)
self.assertEqual(set(self.manifests), set(state.effective_modules))
self.assertEqual({"access", "admin"}, set(state.forced_modules))
def test_system_policy_closes_dependencies_and_tenant_selection(self) -> None:
settings, state = update_system_tenant_module_policy(
{},
self.manifests,
available_modules=("campaigns", "encryption"),
forced_modules=("campaigns",),
enabled_modules=("encryption",),
expected_revision=0,
)
self.assertEqual(1, state.revision)
self.assertEqual(
{"access", "admin", "files", "campaigns", "encryption"},
set(state.available_modules),
)
self.assertEqual(
{"access", "admin", "files", "campaigns"},
set(state.forced_modules),
)
self.assertEqual({"encryption"}, set(state.selected_modules))
self.assertEqual(set(self.manifests), set(state.effective_modules))
self.assertIn("module_entitlements", settings)
def test_tenant_cannot_enable_system_unavailable_module(self) -> None:
settings, _state = update_system_tenant_module_policy(
{},
self.manifests,
available_modules=("files",),
forced_modules=(),
enabled_modules=(),
expected_revision=0,
)
with self.assertRaisesRegex(
ModuleEntitlementError,
"unavailable by system policy: encryption",
):
update_tenant_module_selection(
settings,
self.manifests,
enabled_modules=("encryption",),
expected_revision=1,
)
def test_forced_modules_remain_effective_when_tenant_selection_is_empty(self) -> None:
settings, _state = update_system_tenant_module_policy(
{},
self.manifests,
available_modules=("campaigns",),
forced_modules=("campaigns",),
enabled_modules=(),
expected_revision=0,
)
_settings, state = update_tenant_module_selection(
settings,
self.manifests,
enabled_modules=(),
expected_revision=1,
)
self.assertEqual(
{"access", "admin", "files", "campaigns"},
set(state.effective_modules),
)
self.assertTrue(
all(
not item.tenant_can_toggle
for item in state.modules
if item.id in state.forced_modules
)
)
def test_inactive_runtime_module_is_selected_but_not_effective(self) -> None:
settings, state = update_system_tenant_module_policy(
{},
self.manifests,
available_modules=("files", "encryption"),
forced_modules=(),
enabled_modules=("encryption",),
expected_revision=0,
runtime_active_modules=("access", "admin", "files"),
)
self.assertIn("encryption", state.selected_modules)
self.assertNotIn("encryption", state.effective_modules)
encryption = next(item for item in state.modules if item.id == "encryption")
self.assertIn("not active in the deployment", encryption.reason or "")
self.assertIn("module_entitlements", settings)
def test_stale_revision_is_rejected(self) -> None:
settings, _state = update_system_tenant_module_policy(
{},
self.manifests,
available_modules=("files",),
forced_modules=(),
enabled_modules=("files",),
expected_revision=0,
)
with self.assertRaises(ModuleEntitlementConflict):
update_tenant_module_selection(
settings,
self.manifests,
enabled_modules=(),
expected_revision=0,
)
def test_malformed_document_fails_closed_to_protected_modules(self) -> None:
state = tenant_module_entitlement_state(
{"module_entitlements": {"revision": "invalid"}},
self.manifests,
)
self.assertEqual({"access", "admin"}, set(state.effective_modules))
self.assertTrue(state.diagnostics)
def test_resolver_caches_and_invalidates_tenant_state(self) -> None:
registry = PlatformRegistry()
for manifest in self.manifests.values():
registry.register(manifest)
tenant = SimpleNamespace(id="tenant-1", is_active=True, settings={})
class CountingSession:
calls = 0
def get(self, _model, _tenant_id):
self.calls += 1
return tenant
session = CountingSession()
resolver = TenantModuleEntitlementResolver(
registry,
ttl_seconds=60,
max_entries=2,
)
resolver.resolve(session, "tenant-1")
resolver.resolve(session, "tenant-1")
self.assertEqual(1, session.calls)
resolver.invalidate("tenant-1")
resolver.resolve(session, "tenant-1")
self.assertEqual(2, session.calls)
def test_new_and_accepted_work_have_distinct_disable_semantics(self) -> None:
registry = PlatformRegistry()
for manifest in self.manifests.values():
registry.register(manifest)
settings, _state = update_system_tenant_module_policy(
{},
self.manifests,
available_modules=(),
forced_modules=(),
enabled_modules=(),
expected_revision=0,
)
tenant = SimpleNamespace(
id="tenant-1",
is_active=True,
settings=settings,
)
session = SimpleNamespace(get=lambda _model, _tenant_id: tenant)
resolver = TenantModuleEntitlementResolver(registry, ttl_seconds=0)
with self.assertRaises(TenantModuleUnavailable) as rejected:
resolver.require(
session,
tenant_id="tenant-1",
module_id="files",
work_state="new",
)
self.assertEqual("rejected", rejected.exception.admission.disposition)
with self.assertRaises(TenantModuleOperatorActionRequired) as preserved:
resolver.require(
session,
tenant_id="tenant-1",
module_id="files",
work_state="accepted",
)
self.assertEqual(
"operator_action_required",
preserved.exception.admission.disposition,
)
class TenantModuleEntitlementRouteTests(unittest.TestCase):
def setUp(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-entitlement-test-"))
configure_database(f"sqlite:///{root / 'test.db'}")
create_scope_tables(get_database().engine)
self.manifests = (
ModuleManifest(id="access", name="Access", version="test"),
ModuleManifest(
id="admin",
name="Admin",
version="test",
dependencies=("access",),
),
ModuleManifest(
id="files",
name="Files",
version="test",
dependencies=("access",),
),
)
self.registry = PlatformRegistry()
for manifest in self.manifests:
self.registry.register(manifest)
settings, _state = update_system_tenant_module_policy(
{},
{manifest.id: manifest for manifest in self.manifests},
available_modules=(),
forced_modules=(),
enabled_modules=(),
expected_revision=0,
)
with get_database().session() as session:
session.add(
Tenant(
id="tenant-1",
slug="tenant-1",
name="Tenant 1",
settings=settings,
)
)
session.commit()
self.principal = ApiPrincipal(
principal=PrincipalRef(
account_id="account-1",
membership_id="membership-1",
tenant_id="tenant-1",
),
account=object(),
user=object(),
)
def test_platform_metadata_excludes_tenant_unavailable_module(self) -> None:
app = FastAPI()
app.state.govoplan_registry = self.registry
app.include_router(create_platform_router(), prefix="/api/v1")
app.dependency_overrides[get_api_principal] = lambda: self.principal
with TestClient(app) as client:
response = client.get("/api/v1/platform/modules")
self.assertEqual(200, response.status_code, response.text)
self.assertEqual(
{"access", "admin"},
{item["id"] for item in response.json()["modules"]},
)
self.assertNotIn(
"files",
response.json()["module_entitlement"]["effective_modules"],
)
def test_authenticated_module_route_is_hidden_when_tenant_unavailable(self) -> None:
app = FastAPI()
app.state.govoplan_registry = self.registry
guarded = APIRouter(dependencies=[Depends(require_module_active("files"))])
@guarded.get("/files")
def files_route():
return {"ok": True}
app.include_router(guarded)
with patch(
"govoplan_core.core.lifecycle.get_api_principal",
return_value=self.principal,
), TestClient(app) as client:
response = client.get(
"/files",
headers={"Authorization": "Bearer test"},
)
self.assertEqual(404, response.status_code, response.text)
self.assertEqual(
"Module is unavailable in the active tenant: files",
response.json()["detail"],
)
def test_unauthenticated_public_route_is_not_turned_into_login(self) -> None:
app = FastAPI()
app.state.govoplan_registry = self.registry
guarded = APIRouter(dependencies=[Depends(require_module_active("files"))])
@guarded.get("/public-files")
def public_files_route():
return {"ok": True}
app.include_router(guarded)
with TestClient(app) as client:
response = client.get("/public-files")
self.assertEqual(200, response.status_code, response.text)
def test_public_tenant_route_enforces_module_entitlement(self) -> None:
registry = PlatformRegistry()
for manifest in self.manifests:
registry.register(
ModuleManifest(
id=manifest.id,
name=manifest.name,
version=manifest.version,
dependencies=manifest.dependencies,
public_tenant_resolver=(
(lambda _request, _session: "tenant-1")
if manifest.id == "files"
else None
),
)
)
app = FastAPI()
app.state.govoplan_registry = registry
guarded = APIRouter(dependencies=[Depends(require_module_active("files"))])
@guarded.get("/public-files/{token}")
def public_files_route(token: str):
return {"token": token}
app.include_router(guarded)
with TestClient(app) as client:
response = client.get("/public-files/example")
self.assertEqual(404, response.status_code, response.text)
self.assertEqual(
"Module is unavailable in the active tenant: files",
response.json()["detail"],
)
def test_tenant_capability_rejects_unavailable_provider(self) -> None:
registry = PlatformRegistry()
for manifest in self.manifests:
registry.register(
ModuleManifest(
id=manifest.id,
name=manifest.name,
version=manifest.version,
dependencies=manifest.dependencies,
capability_factories=(
{"files.example": lambda _context: object()}
if manifest.id == "files"
else {}
),
)
)
registry.configure_capability_context(
ModuleContext(registry=registry, settings=SimpleNamespace())
)
with get_database().session() as session:
with self.assertRaises(TenantModuleUnavailable):
registry.require_tenant_capability(
"files.example",
session,
tenant_id="tenant-1",
)
def test_request_context_treats_unavailable_optional_capability_as_absent(self) -> None:
registry = PlatformRegistry()
for manifest in self.manifests:
registry.register(
ModuleManifest(
id=manifest.id,
name=manifest.name,
version=manifest.version,
dependencies=manifest.dependencies,
capability_factories=(
{"files.example": lambda _context: object()}
if manifest.id == "files"
else {}
),
)
)
registry.configure_capability_context(
ModuleContext(registry=registry, settings=SimpleNamespace())
)
app = FastAPI()
app.state.govoplan_registry = registry
guarded = APIRouter(dependencies=[Depends(require_module_active("admin"))])
@guarded.get("/admin-capability")
def admin_capability_route():
return {"files_available": registry.capability("files.example") is not None}
app.include_router(guarded)
with patch(
"govoplan_core.core.lifecycle.get_api_principal",
return_value=self.principal,
), TestClient(app) as client:
response = client.get(
"/admin-capability",
headers={"Authorization": "Bearer test"},
)
self.assertEqual(200, response.status_code, response.text)
self.assertFalse(response.json()["files_available"])
def test_worker_preserves_accepted_work_for_operator_when_disabled(self) -> None:
registry = PlatformRegistry()
for manifest in self.manifests:
registry.register(
ModuleManifest(
id=manifest.id,
name=manifest.name,
version=manifest.version,
dependencies=manifest.dependencies,
capability_factories=(
{"files.worker": lambda _context: object()}
if manifest.id == "files"
else {}
),
)
)
registry.configure_capability_context(
ModuleContext(registry=registry, settings=SimpleNamespace())
)
invoked: list[str] = []
with get_database().session() as session:
result = _run_tenant_worker_batches(
registry,
session,
capability_name="files.worker",
tenant_id="tenant-1",
operation=lambda tenant_id: invoked.append(tenant_id) or {},
defaults={"processed": 0},
)
self.assertEqual([], invoked)
self.assertEqual(1, result["operator_action_required"])
self.assertEqual(
"operator_action_required",
result["operator_actions"][0]["disposition"],
)
if __name__ == "__main__":
unittest.main()
+209 -8
View File
@@ -20,7 +20,7 @@ from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from sqlalchemy import Column, Integer, MetaData, Table, create_engine, insert, inspect
from sqlalchemy import Column, Integer, MetaData, Table, create_engine, insert, inspect, select
from sqlalchemy.orm import Session
# Keep the default app import side effect from bootstrapping a development DB.
@@ -93,13 +93,21 @@ from govoplan_core.core.configuration_packages import (
validate_configuration_package_catalog,
)
from govoplan_core.core.module_license import issue_module_license, module_license_decision, module_license_diagnostics, validate_module_license
from govoplan_core.core.recovery import (
RecoveryCheckpoint,
RecoveryOperation,
RecoveryStatus,
verify_recovery_evidence_chain,
)
from govoplan_core.core.runtime_coordination import DistributedLease
from govoplan_core.core.module_package_catalog import (
module_package_catalog,
record_module_package_catalog_acceptance,
sign_module_package_catalog,
validate_module_package_catalog,
)
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult, PublicFrontendRoute
from govoplan_core.core.information_governance import ModuleInformationGovernance
from govoplan_core.core.modules import DocumentationTopic, FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult, PublicFrontendRoute
from govoplan_core.core.module_guards import drop_table_retirement_provider
from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest, PermissionDefinition, RoleTemplate
from govoplan_core.core.registry import PlatformRegistry, RegistryError
@@ -112,7 +120,8 @@ from govoplan_core.security.module_permissions import scopes_grant_compatible
from govoplan_core.security.permissions import scope_grants
from govoplan_core.server.app import create_app
from govoplan_core.server.config import GovoplanServerConfig
from govoplan_core.server.platform import create_platform_router
from govoplan_core.server.platform import _documentation_help_contexts, create_platform_router
from govoplan_core.core.views import ViewSurface
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.server.route_validation import RouteCollisionError
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
@@ -243,6 +252,7 @@ class ModuleSystemTests(unittest.TestCase):
"postbox",
"approvals",
"reporting",
"search",
),
)
self.assertEqual(manifests["dashboard"].dependencies, ())
@@ -512,6 +522,7 @@ class ModuleSystemTests(unittest.TestCase):
id="example",
name="Example",
version="test",
public_tenant_resolver=lambda _request, _session: "tenant-1",
frontend=FrontendModule(
module_id="example",
package_name="@govoplan/example-webui",
@@ -528,6 +539,16 @@ class ModuleSystemTests(unittest.TestCase):
id="example",
name="Example",
version="test",
public_tenant_resolver=lambda _request, _session: "tenant-1",
documentation=(
DocumentationTopic(
id="example.public-help",
title="Example public help",
summary="Help for the public example route.",
documentation_types=("user",),
metadata={"help_contexts": ["example.public"]},
),
),
frontend=FrontendModule(
module_id="example",
package_name="@govoplan/example-webui",
@@ -562,6 +583,15 @@ class ModuleSystemTests(unittest.TestCase):
self.assertEqual(["example"], [item["id"] for item in response.json()["modules"]])
public_module = response.json()["modules"][0]
self.assertNotIn("dependencies", public_module)
self.assertEqual(
[{
"id": "example.public",
"topic_id": "example.public-help",
"title": "Example public help",
"documentation_types": ["user"],
}],
public_module["help_contexts"],
)
self.assertNotIn("nav", public_module["frontend"])
self.assertNotIn("routes", public_module["frontend"])
self.assertEqual(
@@ -569,6 +599,70 @@ class ModuleSystemTests(unittest.TestCase):
[item["path"] for item in public_module["frontend"]["public_routes"]],
)
def test_declared_surfaces_receive_static_documentation_fallbacks(self) -> None:
manifest = ModuleManifest(
id="example",
name="Example",
version="test",
documentation=(
DocumentationTopic(
id="example.user",
title="Example user guide",
summary="User guide.",
documentation_types=("user",),
order=20,
),
DocumentationTopic(
id="example.admin",
title="Example administrator guide",
summary="Administrator guide.",
documentation_types=("admin",),
order=10,
),
),
frontend=FrontendModule(
module_id="example",
routes=(
FrontendRoute(
path="/example",
component="ExamplePage",
surface_id="example.workspace",
),
),
settings_routes=(
FrontendRoute(
path="/admin?section=example",
component="ExampleAdmin",
surface_id="example.admin.settings",
),
),
view_surfaces=(
ViewSurface(
id="example.workspace",
module_id="example",
kind="route",
label="Example workspace",
),
ViewSurface(
id="example.admin.settings",
module_id="example",
kind="section",
label="Example settings",
),
),
),
)
contexts = {
item["id"]: item for item in _documentation_help_contexts(manifest)
}
self.assertEqual("example.user", contexts["example.workspace"]["topic_id"])
self.assertEqual(
"example.admin",
contexts["example.admin.settings"]["topic_id"],
)
def test_registry_rejects_duplicate_public_frontend_routes(self) -> None:
registry = PlatformRegistry()
for module_id in ("first", "second"):
@@ -576,6 +670,7 @@ class ModuleSystemTests(unittest.TestCase):
id=module_id,
name=module_id.title(),
version="test",
public_tenant_resolver=lambda _request, _session: "tenant-1",
frontend=FrontendModule(
module_id=module_id,
public_routes=(
@@ -2607,7 +2702,15 @@ finally:
settings = _settings(root)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
Base.metadata.create_all(
bind=database.engine,
tables=[
SystemSettings.__table__,
DistributedLease.__table__,
RecoveryOperation.__table__,
RecoveryCheckpoint.__table__,
],
)
metadata = MetaData()
table = Table("retirement_example", metadata, Column("id", Integer, primary_key=True))
metadata.create_all(bind=database.engine)
@@ -2658,6 +2761,9 @@ finally:
database_url=settings.database_url,
runtime_dir=root / "installer",
)
recovery = session.execute(select(RecoveryOperation)).scalar_one()
self.assertEqual(RecoveryStatus.SUCCEEDED.value, recovery.status)
self.assertTrue(verify_recovery_evidence_chain(session, recovery.id))
self.assertEqual("applied", result.status)
self.assertFalse(inspect(database.engine).has_table("retirement_example"))
@@ -2813,7 +2919,15 @@ finally:
settings = _settings(root)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
Base.metadata.create_all(
bind=database.engine,
tables=[
SystemSettings.__table__,
DistributedLease.__table__,
RecoveryOperation.__table__,
RecoveryCheckpoint.__table__,
],
)
def fake_run(*_args, **kwargs):
argv = tuple(_args[0]) if _args else ()
@@ -2846,11 +2960,78 @@ finally:
restored_desired = saved_desired_enabled_modules(session, ("tenancy", "access"))
restored_plan = saved_module_install_plan(session)
recovery = session.execute(select(RecoveryOperation)).scalar_one()
self.assertEqual(RecoveryStatus.RECOVERED.value, recovery.status)
self.assertTrue(verify_recovery_evidence_chain(session, recovery.id))
self.assertEqual("rolled-back", result.status)
self.assertEqual(("tenancy", "access"), restored_desired)
self.assertEqual(("planned",), tuple(item.status for item in restored_plan.items))
def test_module_installer_blocks_after_unresolved_package_effect(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-unresolved-", dir=_TEST_ROOT))
settings = _settings(root)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(
bind=database.engine,
tables=[
SystemSettings.__table__,
DistributedLease.__table__,
RecoveryOperation.__table__,
RecoveryCheckpoint.__table__,
],
)
def fail_package_install(*args, **_kwargs):
argv = tuple(args[0]) if args else ()
if any("govoplan-example==0.1.4" in str(item) for item in argv):
return SimpleNamespace(returncode=1, stdout="", stderr="install failed")
return SimpleNamespace(returncode=0, stdout="", stderr="")
with database.session() as session:
save_maintenance_mode(session, MaintenanceMode(enabled=True))
plan = save_module_install_plan(session, [{
"module_id": "example",
"action": "install",
"python_package": "govoplan-example",
"python_ref": "govoplan-example==0.1.4",
}])
session.commit()
with patch(
"govoplan_core.core.module_installer.subprocess.run",
side_effect=fail_package_install,
):
result = run_module_install_plan(
session=session,
plan=plan,
available=available_module_manifests(),
current_enabled=("tenancy", "access"),
desired_enabled=("tenancy", "access"),
database_url=settings.database_url,
runtime_dir=root / "installer",
)
self.assertEqual("failed", result.status)
recovery = session.execute(select(RecoveryOperation)).scalar_one()
self.assertEqual(RecoveryStatus.RECOVERY_REQUIRED.value, recovery.status)
self.assertTrue(verify_recovery_evidence_chain(session, recovery.id))
with self.assertRaisesRegex(
module_installer_module.ModuleInstallerError,
"already recovery_required",
):
run_module_install_plan(
session=session,
plan=plan,
available=available_module_manifests(),
current_enabled=("tenancy", "access"),
desired_enabled=("tenancy", "access"),
database_url=settings.database_url,
runtime_dir=root / "installer",
)
def test_module_installer_external_database_backup_command_is_recorded(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-external-backup-", dir=_TEST_ROOT))
settings = _settings(root)
@@ -3185,6 +3366,7 @@ finally:
"recovery_notes": "Restore rehearsal completed for the release candidate.",
"provides_interfaces": [{"name": "files.spaces", "version": "1.2.0"}],
"requires_interfaces": [{"name": "access.directory", "version_min": "1.0.0", "optional": True}],
"information_governance": ModuleInformationGovernance().to_dict(),
"artifact_integrity": {
"python": {
"ref": "govoplan-files==0.1.4",
@@ -3230,6 +3412,12 @@ finally:
[{"name": "access.directory", "optional": True, "version_min": "1.0.0"}],
catalog[0]["requires_interfaces"],
)
self.assertEqual(
"contract_only",
catalog[0]["information_governance"]["dimensions"]["retention"][
"adoption"
],
)
self.assertEqual("0" * 64, catalog[0]["artifact_integrity"]["python"]["sha256"])
validation = validate_module_package_catalog(catalog_path)
@@ -3424,7 +3612,7 @@ finally:
"version_max_exclusive": "0.2.0",
}, modules["files"]["requires_interfaces"])
self.assertEqual(
["campaigns", "encryption"],
["campaigns", "encryption", "search"],
modules["files"]["optional_dependencies"],
)
self.assertIn({"name": "mail.campaign_delivery", "version": "0.2.0"}, modules["mail"]["provides_interfaces"])
@@ -3488,13 +3676,17 @@ finally:
"postbox",
"approvals",
"reporting",
"search",
],
modules["campaigns"]["optional_dependencies"],
)
self.assertEqual("requires_review", modules["files"]["migration_safety"])
self.assertIn("migration", modules["files"]["migration_notes"].lower())
self.assertEqual("0.1.9", modules["files"]["version"])
self.assertIn("@v0.1.9", modules["files"]["python_ref"])
files_version = importlib.import_module(
"govoplan_files.backend.manifest"
).get_manifest().version
self.assertEqual(files_version, modules["files"]["version"])
self.assertIn(f"@v{files_version}", modules["files"]["python_ref"])
def test_module_package_catalog_validates_remote_url_and_cache_fallback(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-remote-", dir=_TEST_ROOT))
@@ -4216,6 +4408,15 @@ finally:
app, _settings_obj = self._app_for_modules(())
lifecycle = getattr(app.state, "govoplan_lifecycle", None)
self.assertIsNotNone(lifecycle)
database = get_database()
Base.metadata.create_all(
bind=database.engine,
tables=[
DistributedLease.__table__,
RecoveryOperation.__table__,
RecoveryCheckpoint.__table__,
],
)
with TestClient(app) as client:
response = client.get("/api/v1/platform/modules")
+39 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from datetime import datetime, timezone
from io import BytesIO
from pathlib import Path
import tempfile
@@ -26,10 +27,12 @@ class _S3Error(RuntimeError):
class _FakeS3Client:
def __init__(self) -> None:
self.objects: dict[str, bytes] = {}
self.modified_at: dict[str, datetime] = {}
self.head_error: Exception | None = None
def put_object(self, *, Key: str, Body: bytes, **_kwargs) -> None:
self.objects[Key] = Body
self.modified_at[Key] = datetime.now(timezone.utc)
def get_object(self, *, Key: str, **_kwargs):
try:
@@ -45,10 +48,40 @@ class _FakeS3Client:
payload = self.objects[Key]
except KeyError as exc:
raise _S3Error("NotFound", 404) from exc
return {"ContentLength": len(payload)}
return {
"ContentLength": len(payload),
"LastModified": self.modified_at[Key],
}
def delete_object(self, *, Key: str, **_kwargs) -> None:
self.objects.pop(Key, None)
self.modified_at.pop(Key, None)
def list_objects_v2(
self,
*,
Prefix: str,
MaxKeys: int,
StartAfter: str | None = None,
**_kwargs,
):
keys = [
key
for key in sorted(self.objects)
if key.startswith(Prefix) and (StartAfter is None or key > StartAfter)
]
selected = keys[:MaxKeys]
return {
"Contents": [
{
"Key": key,
"Size": len(self.objects[key]),
"LastModified": self.modified_at[key],
}
for key in selected
],
"IsTruncated": len(keys) > len(selected),
}
class ObjectStorageTests(unittest.TestCase):
@@ -67,6 +100,7 @@ class ObjectStorageTests(unittest.TestCase):
self.assertEqual(b"a", backend.get_bytes("campaign/a.eml"))
self.assertEqual(1, backend.stat("campaign/a.eml").size_bytes)
self.assertIsNotNone(backend.stat("campaign/a.eml").modified_at)
self.assertEqual(
("campaign/a.eml",), tuple(item.key for item in first.objects)
)
@@ -134,6 +168,10 @@ class ObjectStorageTests(unittest.TestCase):
backend.put_bytes("campaign/message.eml", b"message/rfc822")
self.assertTrue(backend.exists("campaign/message.eml"))
self.assertEqual(b"message/rfc822", backend.get_bytes("campaign/message.eml"))
self.assertIsNotNone(backend.stat("campaign/message.eml").modified_at)
self.assertIsNotNone(
backend.list_objects(prefix="campaign/").objects[0].modified_at
)
self.assertFalse(backend.exists("campaign/missing.eml"))
client.head_error = _S3Error("AccessDenied", 403)
+145 -6
View File
@@ -10,6 +10,10 @@ from govoplan_core.celery_app import (
purge_platform_events,
)
from govoplan_core.core.events import PlatformEvent
from govoplan_core.core.dataflows import CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER
from govoplan_core.core.events import CAPABILITY_PLATFORM_EVENT_OUTBOX
from govoplan_core.core.search import CAPABILITY_SEARCH_INDEX_WRITER
from tests.worker_test_support import allowed_worker_admissions
class PlatformEventWorkerTests(unittest.TestCase):
@@ -18,16 +22,30 @@ class PlatformEventWorkerTests(unittest.TestCase):
database = MagicMock()
database.SessionLocal.return_value.__enter__.return_value = session
outbox = MagicMock()
outbox.dispatch_pending.return_value = {
outbox.dispatch_pending.side_effect = (
{
"selected": 1,
"delivered": 1,
"retrying": 0,
"quarantined": 0,
"dispatched": 1,
"observer_failed": 0,
}
},
{
"selected": 0,
"delivered": 0,
"retrying": 0,
"quarantined": 0,
"dispatched": 0,
"observer_failed": 0,
},
)
dataflow = MagicMock()
registry = MagicMock()
registry.has_capability.side_effect = lambda name: name in {
CAPABILITY_PLATFORM_EVENT_OUTBOX,
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER,
}
with (
patch(
@@ -46,16 +64,29 @@ class PlatformEventWorkerTests(unittest.TestCase):
"govoplan_core.celery_app._workflow_trigger_dispatcher",
return_value=None,
),
patch(
"govoplan_core.celery_app._search_index_coordinator",
return_value=None,
),
patch(
"govoplan_core.db.session.get_database",
return_value=database,
),
patch(
"govoplan_core.celery_app._worker_admissions",
side_effect=allowed_worker_admissions,
),
):
result = dispatch_platform_events.run(25)
call = outbox.dispatch_pending.call_args
self.assertEqual(2, outbox.dispatch_pending.call_count)
call = outbox.dispatch_pending.call_args_list[0]
self.assertEqual(session, call.args[0])
self.assertEqual(25, call.kwargs["limit"])
self.assertEqual("tenant-1", call.kwargs["tenant_id"])
system_call = outbox.dispatch_pending.call_args_list[1]
self.assertTrue(system_call.kwargs["tenantless_only"])
self.assertIsNone(system_call.kwargs["tenant_id"])
consumer = call.kwargs["consumers"][0]
self.assertEqual(
"dataflow.event-triggers.v1",
@@ -77,14 +108,114 @@ class PlatformEventWorkerTests(unittest.TestCase):
session.commit.assert_called_once_with()
self.assertEqual(1, result["delivered"])
def test_dispatch_uses_a_durable_search_consumer_and_processes_changes(self) -> None:
session = MagicMock()
database = MagicMock()
database.SessionLocal.return_value.__enter__.return_value = session
outbox = MagicMock()
outbox.dispatch_pending.side_effect = (
{
"selected": 1,
"delivered": 1,
"retrying": 0,
"quarantined": 0,
"dispatched": 1,
"observer_failed": 0,
},
{
"selected": 0,
"delivered": 0,
"retrying": 0,
"quarantined": 0,
"dispatched": 0,
"observer_failed": 0,
},
)
search = MagicMock()
search.process_changes.return_value = {
"selected": 1,
"applied": 1,
"retrying": 0,
"quarantined": 0,
}
registry = MagicMock()
registry.has_capability.side_effect = lambda name: name in {
CAPABILITY_PLATFORM_EVENT_OUTBOX,
CAPABILITY_SEARCH_INDEX_WRITER,
}
with (
patch(
"govoplan_core.celery_app._platform_registry",
return_value=registry,
),
patch(
"govoplan_core.celery_app._platform_event_outbox",
return_value=outbox,
),
patch(
"govoplan_core.celery_app._dataflow_trigger_dispatcher",
return_value=None,
),
patch(
"govoplan_core.celery_app._workflow_trigger_dispatcher",
return_value=None,
),
patch(
"govoplan_core.celery_app._search_index_coordinator",
return_value=search,
),
patch(
"govoplan_core.db.session.get_database",
return_value=database,
),
patch(
"govoplan_core.celery_app._worker_admissions",
side_effect=allowed_worker_admissions,
),
):
result = dispatch_platform_events.run(25)
consumer = outbox.dispatch_pending.call_args_list[0].kwargs[
"consumers"
][0]
self.assertEqual("search.indexing.v1", consumer.consumer_id)
self.assertEqual(frozenset({"*"}), consumer.event_types)
event = PlatformEvent(type="files.file.updated", module_id="files")
delivery_key = consumer.delivery_key(event)
consumer.handler(event, delivery_key)
search.ingest_event.assert_called_once_with(
session,
event=event,
delivery_key=delivery_key,
)
search.process_changes.assert_called_once_with(
session,
tenant_id="tenant-1",
limit=25,
)
self.assertEqual(1, result["search_changes"]["applied"])
session.commit.assert_called_once_with()
def test_retention_task_uses_the_configured_terminal_window(self) -> None:
session = MagicMock()
database = MagicMock()
database.SessionLocal.return_value.__enter__.return_value = session
outbox = MagicMock()
outbox.purge_terminal.return_value = {"deleted": 2}
outbox.purge_terminal.side_effect = (
{"deleted": 2},
{"deleted": 1},
)
registry = MagicMock()
registry.has_capability.side_effect = lambda name: (
name == CAPABILITY_PLATFORM_EVENT_OUTBOX
)
with (
patch(
"govoplan_core.celery_app._platform_registry",
return_value=registry,
),
patch(
"govoplan_core.celery_app._platform_event_outbox",
return_value=outbox,
@@ -98,17 +229,25 @@ class PlatformEventWorkerTests(unittest.TestCase):
"platform_event_outbox_terminal_retention_days",
30,
),
patch(
"govoplan_core.celery_app._worker_admissions",
side_effect=allowed_worker_admissions,
),
):
result = purge_platform_events.run(75)
call = outbox.purge_terminal.call_args
self.assertEqual(2, outbox.purge_terminal.call_count)
call = outbox.purge_terminal.call_args_list[0]
self.assertEqual(session, call.args[0])
self.assertEqual(75, call.kwargs["limit"])
self.assertEqual("tenant-1", call.kwargs["tenant_id"])
system_call = outbox.purge_terminal.call_args_list[1]
self.assertTrue(system_call.kwargs["tenantless_only"])
before = call.kwargs["before"]
self.assertIsInstance(before, datetime)
self.assertEqual(timezone.utc, before.tzinfo)
session.commit.assert_called_once_with()
self.assertEqual({"deleted": 2}, result)
self.assertEqual(3, result["deleted"])
def test_worker_routes_and_periodic_tasks_are_registered(self) -> None:
self.assertEqual(
+131
View File
@@ -0,0 +1,131 @@
from __future__ import annotations
import unittest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.modules import (
FrontendModule,
FrontendRoute,
ModuleInterfaceProvider,
ModuleManifest,
NavItem,
)
from govoplan_core.core.platform_interfaces import (
manifest_interface_catalog,
manifest_interface_declarations,
)
from govoplan_core.core.registry import PlatformRegistry, RegistryError
from govoplan_core.server.platform import create_platform_router
def _principal(*scopes: str) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id="account-1",
membership_id=None,
tenant_id=None,
scopes=frozenset(scopes),
),
account=object(),
user=object(),
)
def _manifest() -> ModuleManifest:
navigation = NavItem(path="/example", label="Example", icon="box")
return ModuleManifest(
id="example",
name="Example",
version="1.2.3",
provides_interfaces=(
ModuleInterfaceProvider(name="example.reader", version="1.0.0"),
),
capability_factories={"example.reader": lambda _context: object()},
nav_items=(navigation,),
frontend=FrontendModule(
module_id="example",
routes=(
FrontendRoute(path="/example", component="ExamplePage"),
),
nav_items=(navigation,),
),
)
class PlatformInterfaceCatalogTests(unittest.TestCase):
def test_manifest_declarations_have_stable_typed_keys(self) -> None:
declarations = manifest_interface_declarations(_manifest())
keys = {item.key for item in declarations}
self.assertIn("backend_capability:example.reader", keys)
self.assertIn("provided_interface:example.reader", keys)
self.assertIn("frontend_route:example.route.example", keys)
self.assertIn("navigation:example.nav.example", keys)
self.assertEqual(
1,
sum(item.key == "navigation:example.nav.example" for item in declarations),
)
def test_catalog_digest_is_deterministic(self) -> None:
first = manifest_interface_catalog(_manifest())
second = manifest_interface_catalog(_manifest())
self.assertEqual(first["digest"], second["digest"])
self.assertEqual("1", first["contract_version"])
def test_registry_rejects_conflicting_duplicate_navigation(self) -> None:
manifest = _manifest()
manifest = ModuleManifest(
id=manifest.id,
name=manifest.name,
version=manifest.version,
nav_items=manifest.nav_items,
frontend=FrontendModule(
module_id="example",
nav_items=(NavItem(path="/example", label="Other"),),
),
)
registry = PlatformRegistry()
registry.register(manifest)
with self.assertRaisesRegex(RegistryError, "duplicate platform interface"):
registry.validate()
def test_read_only_endpoint_requires_administrator_scope(self) -> None:
registry = PlatformRegistry()
registry.register(_manifest())
registry.validate()
app = FastAPI()
app.state.govoplan_registry = registry
app.include_router(create_platform_router(), prefix="/api/v1")
app.dependency_overrides[get_api_principal] = lambda: _principal()
with TestClient(app) as client:
denied = client.get("/api/v1/platform/interface-catalog")
self.assertEqual(403, denied.status_code)
app.dependency_overrides[get_api_principal] = lambda: _principal(
"admin:module:read"
)
with TestClient(app) as client:
response = client.get("/api/v1/platform/interface-catalog")
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertEqual("1", payload["contract_version"])
self.assertEqual(["example"], [item["module_id"] for item in payload["modules"]])
self.assertIn(
"frontend_route:example.route.example",
{
item["key"]
for item in payload["modules"][0]["declarations"]
},
)
if __name__ == "__main__":
unittest.main()
@@ -18,6 +18,9 @@ class _CompleteGateway:
def resolve_participation(self, *args, **kwargs):
raise NotImplementedError
def resolve_public_invitation(self, *args, **kwargs):
raise NotImplementedError
def submit_governed_response(self, *args, **kwargs):
raise NotImplementedError
+5
View File
@@ -11,6 +11,7 @@ from govoplan_core.core.postbox import (
postbox_routing_provider,
)
from govoplan_core.core.registry import PlatformRegistry
from tests.worker_test_support import allowed_worker_admissions
class _RoutingProvider:
@@ -68,6 +69,10 @@ class PostboxRoutingWorkerTests(unittest.TestCase):
"govoplan_core.db.session.get_database",
return_value=database,
),
patch(
"govoplan_core.celery_app._worker_admissions",
side_effect=allowed_worker_admissions,
),
):
result = dispatch_postbox_routes.run("tenant-1", 25)
+277 -2
View File
@@ -1,8 +1,9 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
from sqlalchemy import create_engine, select
from sqlalchemy import Column, MetaData, String, Table, create_engine, select
from sqlalchemy.orm import sessionmaker
import pytest
@@ -49,7 +50,13 @@ def _identity(node: str, incarnation: str) -> RuntimeIdentity:
)
def _start(factory, identity, *, key: str = "build-1"):
def _start(
factory,
identity,
*,
key: str = "build-1",
block_unresolved_resource: bool = False,
):
return begin_durable_recovery_operation(
factory,
identity=identity,
@@ -67,6 +74,25 @@ def _start(factory, identity, *, key: str = "build-1"):
lease_resource_key="campaign:build:version-1",
resource_type="campaign_version",
resource_id="version-1",
block_unresolved_resource=block_unresolved_resource,
)
def _start_atomic(factory, identity, *, key: str = "sync-1"):
return begin_durable_recovery_operation(
factory,
identity=identity,
module_id="connectors",
operation_type="read-snapshot",
idempotency_key=key,
request={"provider_id": "provider-1", "cursor": "revision-1"},
recovery_plan=RecoveryPlan(
mode=RecoveryMode.ATOMIC,
preconditions=("the provider read is non-mutating",),
verification_steps=("compare the committed projection",),
),
precondition_evidence={"provider_mutation": False},
lease_resource_key="connectors:provider-1",
)
@@ -102,6 +128,110 @@ def test_durable_operation_commits_before_caller_effect_and_replays_success() ->
engine.dispose()
def test_atomic_terminal_commits_domain_rows_and_recovery_evidence_together() -> None:
engine, factory = _fixture()
metadata = MetaData()
projection = Table(
"test_recovery_projection",
metadata,
Column("id", String(36), primary_key=True),
)
metadata.create_all(engine)
try:
started = _start_atomic(factory, _identity("worker-1", "incarnation-1"))
assert started.operation is not None
with factory() as session:
session.execute(projection.insert().values(id="projection-1"))
started.operation.commit_atomic_success(
session,
evidence={
"verified": True,
"checks": {"projection_id": "projection-1"},
},
)
with factory() as session:
assert session.scalar(select(projection.c.id)) == "projection-1"
operation = session.get(RecoveryOperation, started.operation_id)
assert operation is not None
assert operation.status == RecoveryStatus.SUCCEEDED.value
assert verify_recovery_evidence_chain(session, operation.id)
finally:
engine.dispose()
def test_verified_external_success_commits_projection_and_evidence_together() -> None:
engine, factory = _fixture()
metadata = MetaData()
projection = Table(
"test_verified_external_projection",
metadata,
Column("id", String(36), primary_key=True),
)
metadata.create_all(engine)
try:
started = _start(factory, _identity("worker-1", "incarnation-1"))
assert started.operation is not None
with factory() as session:
session.execute(projection.insert().values(id="projection-1"))
started.operation.commit_verified_success(
session,
evidence={
"verified": True,
"checks": {
"provider_result": "accepted",
"projection_id": "projection-1",
},
},
)
with factory() as session:
assert session.scalar(select(projection.c.id)) == "projection-1"
operation = session.get(RecoveryOperation, started.operation_id)
assert operation is not None
assert operation.mode == RecoveryMode.COMPENSATION.value
assert operation.status == RecoveryStatus.SUCCEEDED.value
assert verify_recovery_evidence_chain(session, operation.id)
finally:
engine.dispose()
def test_failed_atomic_commit_rolls_back_domain_and_terminal_checkpoint() -> None:
engine, factory = _fixture()
metadata = MetaData()
projection = Table(
"test_recovery_projection_rollback",
metadata,
Column("id", String(36), primary_key=True),
)
metadata.create_all(engine)
try:
started = _start_atomic(factory, _identity("worker-1", "incarnation-1"))
assert started.operation is not None
with factory() as session:
session.execute(projection.insert().values(id="rolled-back"))
with (
patch.object(session, "commit", side_effect=RuntimeError("commit failed")),
pytest.raises(RuntimeError, match="commit failed"),
):
started.operation.commit_atomic_success(
session,
evidence={
"verified": True,
"checks": {"projection_id": "rolled-back"},
},
)
with factory() as session:
assert session.execute(select(projection.c.id)).all() == []
operation = session.get(RecoveryOperation, started.operation_id)
assert operation is not None
assert operation.status == RecoveryStatus.RUNNING.value
assert verify_recovery_evidence_chain(session, operation.id)
finally:
engine.dispose()
def test_same_fence_cannot_start_duplicate_running_operation() -> None:
engine, factory = _fixture()
try:
@@ -115,6 +245,29 @@ def test_same_fence_cannot_start_duplicate_running_operation() -> None:
engine.dispose()
def test_verified_provider_rejection_is_terminal_without_recovery() -> None:
engine, factory = _fixture()
try:
started = _start(factory, _identity("worker-1", "incarnation-1"))
assert started.operation is not None
started.operation.reject(
summary="Provider definitively rejected the request",
evidence={
"verified": True,
"provider_outcome": "rejected",
"checks": {"provider_response": "definitive-rejection"},
},
)
with factory() as session:
operation = session.get(RecoveryOperation, started.operation_id)
assert operation is not None
assert operation.status == RecoveryStatus.REJECTED.value
assert operation.completed_at is not None
assert verify_recovery_evidence_chain(session, operation.id)
finally:
engine.dispose()
def test_other_runtime_cannot_use_an_active_fence() -> None:
engine, factory = _fixture()
try:
@@ -127,6 +280,36 @@ def test_other_runtime_cannot_use_an_active_fence() -> None:
engine.dispose()
def test_unresolved_predecessor_can_block_new_effects_on_same_resource() -> None:
engine, factory = _fixture()
try:
started = _start(
factory,
_identity("worker-1", "incarnation-1"),
block_unresolved_resource=True,
)
assert started.operation is not None
started.operation.unresolved(
status=RecoveryStatus.OUTCOME_UNKNOWN,
summary="Provider outcome is unknown",
evidence={"request_sent": True},
failure_summary="Reconcile before retry",
)
with pytest.raises(
RecoveryOperationStateConflict,
match="outcome_unknown",
):
_start(
factory,
_identity("worker-2", "incarnation-2"),
key="build-2",
block_unresolved_resource=True,
)
finally:
engine.dispose()
def test_expired_crash_fence_is_taken_over_as_recovery_required() -> None:
engine, factory = _fixture()
try:
@@ -180,3 +363,95 @@ def test_tampered_checkpoint_blocks_verified_success() -> None:
)
finally:
engine.dispose()
@pytest.mark.parametrize(
("effect_occurred", "expected_status"),
[
(True, RecoveryStatus.SUCCEEDED.value),
(False, RecoveryStatus.RECOVERED.value),
],
)
def test_unknown_provider_outcome_can_be_resolved_from_external_evidence(
effect_occurred: bool,
expected_status: str,
) -> None:
engine, factory = _fixture()
try:
started = _start(factory, _identity("worker-1", "incarnation-1"))
assert started.operation is not None
started.operation.unresolved(
status=RecoveryStatus.OUTCOME_UNKNOWN,
summary="Provider outcome is unknown",
evidence={"effect_started": True},
failure_summary="Inspect the provider before retrying",
)
recovery = claim_durable_recovery_operation(
factory,
identity=_identity("worker-2", "incarnation-2"),
operation_id=started.operation_id,
)
recovery.resolve_unknown(
effect_occurred=effect_occurred,
summary="Operator verified the provider outcome",
evidence={
"verified": True,
"checks": {"provider_evidence": "case-1"},
"effect_occurred": effect_occurred,
"reference": "case-1",
},
)
with factory() as session:
operation = session.get(RecoveryOperation, started.operation_id)
assert operation is not None
assert operation.status == expected_status
assert verify_recovery_evidence_chain(session, operation.id)
finally:
engine.dispose()
def test_unknown_resolution_commits_domain_projection_and_evidence_together() -> None:
engine, factory = _fixture()
metadata = MetaData()
projection = Table(
"test_unknown_resolution_projection",
metadata,
Column("id", String(36), primary_key=True),
)
metadata.create_all(engine)
try:
started = _start(factory, _identity("worker-1", "incarnation-1"))
assert started.operation is not None
started.operation.unresolved(
status=RecoveryStatus.OUTCOME_UNKNOWN,
summary="Provider outcome is unknown",
evidence={"effect_started": True},
failure_summary="Inspect the provider before retrying",
)
recovery = claim_durable_recovery_operation(
factory,
identity=_identity("worker-2", "incarnation-2"),
operation_id=started.operation_id,
)
with factory() as session:
session.execute(projection.insert().values(id="confirmed-effect"))
recovery.commit_unknown_resolution(
session,
effect_occurred=True,
summary="Operator verified the provider outcome",
evidence={
"verified": True,
"checks": {"provider_evidence": "case-1"},
"effect_occurred": True,
},
)
with factory() as session:
assert session.scalar(select(projection.c.id)) == "confirmed-effect"
operation = session.get(RecoveryOperation, started.operation_id)
assert operation is not None
assert operation.status == RecoveryStatus.SUCCEEDED.value
assert verify_recovery_evidence_chain(session, operation.id)
finally:
engine.dispose()
+68 -1
View File
@@ -6,6 +6,8 @@ from types import SimpleNamespace
from govoplan_core.core.runtime_coordination import (
RuntimeCoordinationError,
RuntimeIdentity,
bind_process_runtime_identity,
process_runtime_identity,
)
from govoplan_core.server.runtime_agent import RuntimeNodeAgent
@@ -38,6 +40,22 @@ class _Consumer:
self.added.append(queue)
def test_api_runtime_identity_uses_the_installed_distribution_version(
monkeypatch,
) -> None:
from govoplan_core.server import default_config
monkeypatch.setattr(
default_config,
"version",
lambda distribution: "0.1.15"
if distribution == "govoplan-core"
else "unexpected",
)
assert default_config._core_distribution_version() == "0.1.15"
def test_api_runtime_agent_fails_readiness_on_heartbeat_error() -> None:
agent = RuntimeNodeAgent(
settings=SimpleNamespace(
@@ -64,6 +82,32 @@ def test_api_runtime_agent_fails_readiness_on_heartbeat_error() -> None:
assert agent.coordination_healthy is True
def test_process_runtime_identity_is_explicit_and_replaceable() -> None:
from govoplan_core.core import runtime_coordination
previous = runtime_coordination._process_runtime_identity
identity = RuntimeIdentity(
installation_id="installation-1",
node_id="api-1",
incarnation="incarnation-1",
role="api",
software_version="0.1.14",
composition_hash="a" * 64,
)
try:
bind_process_runtime_identity(None)
try:
process_runtime_identity()
except RuntimeCoordinationError:
pass
else: # pragma: no cover - assertion branch
raise AssertionError("An unbound process identity must fail closed")
bind_process_runtime_identity(identity)
assert process_runtime_identity() is identity
finally:
bind_process_runtime_identity(previous)
def test_worker_disables_consumers_without_reclaiming_stale_identity(
monkeypatch,
) -> None:
@@ -127,8 +171,19 @@ def test_worker_disables_consumers_without_reclaiming_stale_identity(
def test_worker_child_replaces_inherited_database_pool(monkeypatch) -> None:
from govoplan_core import celery_app
from govoplan_core.core import runtime_coordination
calls: list[tuple[str, bool]] = []
previous_process_identity = runtime_coordination._process_runtime_identity
previous_worker_identity = celery_app._worker_identity
inherited = RuntimeIdentity(
installation_id="installation-1",
node_id="parent-worker",
incarnation="parent-incarnation",
role="worker",
software_version="0.1.14",
composition_hash="a" * 64,
)
monkeypatch.setattr(
celery_app,
"configure_database",
@@ -136,7 +191,19 @@ def test_worker_child_replaces_inherited_database_pool(monkeypatch) -> None:
(url, dispose_previous)
),
)
try:
celery_app._worker_identity = inherited
bind_process_runtime_identity(inherited)
celery_app._reset_worker_process_database()
assert calls == [(celery_app.settings.database_url, True)]
assert celery_app._worker_identity is None
try:
process_runtime_identity()
except RuntimeCoordinationError:
pass
else: # pragma: no cover - assertion branch
raise AssertionError("A worker child must discard inherited authority")
finally:
celery_app._worker_identity = previous_worker_identity
bind_process_runtime_identity(previous_process_identity)
+66
View File
@@ -2,17 +2,25 @@ from __future__ import annotations
import unittest
from govoplan_core.core.datasources import (
DatasourceDescriptor,
DatasourceReadRequest,
DatasourceReadResult,
)
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.tabular_sources import (
CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER,
CAPABILITY_CONNECTORS_TABULAR_SOURCES,
TabularColumn,
TabularPreviewDiagnostic,
TabularPushdown,
TabularReadRequest,
TabularReadResult,
TabularSnapshotInput,
TabularSnapshotWriter,
TabularSource,
TabularSourceHealth,
TabularSourceProvider,
TabularSourceValidationError,
parse_tabular_csv,
@@ -30,6 +38,13 @@ class _TabularProvider:
schema=(TabularColumn(name="case_id", data_type="string", nullable=False),),
fingerprint="abc123",
row_count=1,
source_mode="cached",
pushdown=TabularPushdown(projections=True, pagination=True),
health=TabularSourceHealth(
status="healthy",
code="snapshot.ready",
summary="Immutable snapshot is ready.",
),
)
def list_sources(self, session, principal, *, query="", limit=100):
@@ -51,6 +66,18 @@ class _TabularProvider:
rows=selected,
total_rows=len(rows),
truncated=len(selected) < len(rows),
returned_bytes=18,
elapsed_ms=1,
effective_row_limit=request.limit,
effective_byte_limit=request.max_bytes,
effective_timeout_ms=request.timeout_ms,
diagnostics=(
TabularPreviewDiagnostic(
severity="info",
code="preview.bounded",
message="The preview used explicit budgets.",
),
),
)
def create_snapshot(self, session, principal, *, snapshot):
@@ -96,8 +123,47 @@ class TabularSourceContractTests(unittest.TestCase):
)
self.assertEqual(({"case_id": "A-1"},), result.rows)
self.assertEqual("cached", result.source.source_mode)
self.assertTrue(result.source.pushdown.projections)
self.assertEqual("healthy", result.source.health.status)
self.assertEqual("preview.bounded", result.diagnostics[0].code)
self.assertEqual(1_000_000, request.max_bytes)
self.assertEqual(2_000, request.timeout_ms)
self.assertEqual(provider.source, provider.create_snapshot(object(), object(), snapshot=snapshot))
def test_datasource_read_contract_preserves_live_preview_evidence(self) -> None:
request = DatasourceReadRequest(datasource_ref="datasource:monthly-cases")
result = DatasourceReadResult(
datasource=DatasourceDescriptor(
ref=request.datasource_ref,
source_name="monthly_cases",
name="Monthly cases",
kind="database",
mode="live",
shape="tabular",
),
rows=(),
total_rows=0,
truncated=False,
returned_bytes=2,
elapsed_ms=3,
effective_row_limit=request.limit,
effective_byte_limit=request.max_bytes,
effective_timeout_ms=request.timeout_ms,
diagnostics=(
TabularPreviewDiagnostic(
severity="info",
code="preview.complete",
message="The bounded preview completed.",
),
),
)
self.assertEqual(1_000_000, request.max_bytes)
self.assertEqual(2_000, request.timeout_ms)
self.assertEqual(2, result.returned_bytes)
self.assertEqual("preview.complete", result.diagnostics[0].code)
def test_shared_csv_parser_preserves_identifier_zeroes_and_rejects_extra_values(self) -> None:
rows = parse_tabular_csv(
"case_id;amount;active\n0012;7.5;true\n\n",
+126
View File
@@ -0,0 +1,126 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import unittest
from fastapi import APIRouter
from fastapi.testclient import TestClient
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.temporal import (
TemporalContextError,
current_temporal_data_context,
parse_temporal_data_context,
temporal_revision_matches,
)
from govoplan_core.server.fastapi import create_govoplan_app
NOW = datetime(2026, 8, 4, 12, 0, tzinfo=UTC)
class TemporalContextTests(unittest.TestCase):
def test_valid_and_recorded_time_are_independent(self) -> None:
context = parse_temporal_data_context(
validity_mode="at",
valid_at="2025-02-03T10:30:00+01:00",
recorded_at="2025-03-01T00:00:00Z",
evaluated_at=NOW,
)
self.assertEqual("at", context.validity_mode)
self.assertEqual(datetime(2025, 2, 3, 9, 30, tzinfo=UTC), context.valid_at)
self.assertEqual(datetime(2025, 3, 1, tzinfo=UTC), context.recorded_at)
self.assertFalse(context.is_default)
def test_at_requires_zoned_valid_at_and_other_modes_reject_it(self) -> None:
with self.assertRaisesRegex(TemporalContextError, "requires valid_at"):
parse_temporal_data_context(validity_mode="at", evaluated_at=NOW)
with self.assertRaisesRegex(TemporalContextError, "include a timezone"):
parse_temporal_data_context(
validity_mode="at",
valid_at="2025-02-03T10:30:00",
evaluated_at=NOW,
)
with self.assertRaisesRegex(TemporalContextError, "only permitted"):
parse_temporal_data_context(
validity_mode="all",
valid_at="2025-02-03T10:30:00Z",
evaluated_at=NOW,
)
def test_revision_matching_uses_half_open_valid_and_recorded_intervals(self) -> None:
context = parse_temporal_data_context(
validity_mode="at",
valid_at="2025-02-10T00:00:00Z",
recorded_at="2025-02-15T00:00:00Z",
evaluated_at=NOW,
)
self.assertTrue(
temporal_revision_matches(
context,
valid_from=datetime(2025, 2, 1, tzinfo=UTC),
valid_to=datetime(2025, 3, 1, tzinfo=UTC),
revision_recorded_at=datetime(2025, 2, 5, tzinfo=UTC),
superseded_at=datetime(2025, 2, 16, tzinfo=UTC),
)
)
self.assertFalse(
temporal_revision_matches(
context,
valid_from=datetime(2025, 2, 1, tzinfo=UTC),
valid_to=datetime(2025, 2, 10, tzinfo=UTC),
revision_recorded_at=datetime(2025, 2, 5, tzinfo=UTC),
)
)
self.assertFalse(
temporal_revision_matches(
context,
valid_from=datetime(2025, 2, 1, tzinfo=UTC),
revision_recorded_at=datetime(2025, 2, 15, tzinfo=UTC)
+ timedelta(microseconds=1),
)
)
def test_request_headers_bind_context_and_invalid_headers_fail_closed(self) -> None:
router = APIRouter()
@router.get("/temporal")
def temporal_payload() -> dict[str, str | None]:
return current_temporal_data_context().to_dict()
app = create_govoplan_app(
title="temporal context test",
version="test",
registry=PlatformRegistry(),
api_router=router,
)
with TestClient(app) as client:
response = client.get(
"/temporal",
headers={
"X-Govoplan-Validity-Mode": "at",
"X-Govoplan-Valid-At": "2025-02-03T10:30:00Z",
"X-Govoplan-Recorded-At": "2025-03-01T00:00:00Z",
},
)
self.assertEqual(200, response.status_code, response.text)
self.assertEqual("at", response.json()["validity_mode"])
self.assertEqual("2025-02-03T10:30:00Z", response.json()["valid_at"])
self.assertEqual("at", response.headers["X-Govoplan-Validity-Mode"])
self.assertIn(
"x-govoplan-valid-at",
response.headers.get("vary", "").lower(),
)
invalid = client.get(
"/temporal",
headers={"X-Govoplan-Validity-Mode": "at"},
)
self.assertEqual(400, invalid.status_code, invalid.text)
self.assertIn("requires valid_at", invalid.json()["detail"])
if __name__ == "__main__":
unittest.main()
+66 -1
View File
@@ -1,8 +1,16 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import unittest
from govoplan_core.core.voting import VotingResult
from govoplan_core.core.voting import (
VOTING_CERTIFICATION_CERTIFIED,
VOTING_CERTIFICATION_IN_EVALUATION,
VotingCapabilityError,
VotingProviderAssuranceDeclaration,
VotingResult,
require_voting_provider_assurance,
)
def result_with_evidence(*evidence):
@@ -23,7 +31,64 @@ def result_with_evidence(*evidence):
)
class FakeProvider:
def __init__(self, declaration: VotingProviderAssuranceDeclaration) -> None:
self.declaration = declaration
def assurance_declaration(self) -> VotingProviderAssuranceDeclaration:
return self.declaration
def finalize_ballot(self, session, principal, *, request):
raise NotImplementedError
class VotingContractTests(unittest.TestCase):
def test_external_certification_requires_current_evidence_backed_claim(self) -> None:
now = datetime.now(UTC)
declaration = VotingProviderAssuranceDeclaration(
provider_id="certified_provider",
implementation_ref="certified-provider/adapter@1",
supported_assurance_profiles=("external_certified",),
certification_state=VOTING_CERTIFICATION_CERTIFIED,
protocol_ref="vendor:certified-ballot",
protocol_version="3.0",
certification_authority="Independent authority",
certification_reference="certificate-2026-1",
certification_evidence_ref="evidence://certificate-2026-1",
certification_valid_from=now - timedelta(days=1),
certification_valid_until=now + timedelta(days=1),
)
selected = require_voting_provider_assurance(
FakeProvider(declaration),
provider_id="certified_provider",
assurance_profile="external_certified",
at=now,
)
self.assertEqual("certificate-2026-1", selected.certification_reference)
self.assertEqual(
(now - timedelta(days=1)).isoformat(),
selected.to_dict()["certification_valid_from"],
)
def test_external_certification_rejects_evaluation_only_provider(self) -> None:
declaration = VotingProviderAssuranceDeclaration(
provider_id="candidate_provider",
implementation_ref="candidate-provider/adapter@1",
supported_assurance_profiles=("external_certified",),
certification_state=VOTING_CERTIFICATION_IN_EVALUATION,
protocol_ref="vendor:candidate-ballot",
protocol_version="1.0",
)
with self.assertRaisesRegex(VotingCapabilityError, "currently valid"):
require_voting_provider_assurance(
FakeProvider(declaration),
provider_id="candidate_provider",
assurance_profile="external_certified",
)
def test_accepts_sanitized_provider_evidence(self) -> None:
value = result_with_evidence(
{

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