feat: add institutional governance and recovery contracts

This commit is contained in:
2026-08-01 17:46:54 +02:00
parent b65b48832b
commit 7192d32e65
61 changed files with 12539 additions and 168 deletions
+2
View File
@@ -45,6 +45,8 @@ tools/checks/check-focused.sh
- Prefer `rg`, `sed`, and targeted tests over broad recursive scans or full builds.
- Avoid DataGrid changes unless explicitly requested; it is intentionally brittle and has known deferred work.
- Do not add module-to-module imports for optional integrations. Use core registry/capability/module metadata paths.
- Treat documentation as part of every behavior change. Update the owning module's manifest-driven `DocumentationTopic` contributions for each affected user and administrator workflow, setting, permission, limitation, and operational consequence. Feature modules own this content; `govoplan-docs` projects it and must not import feature internals.
- Keep a static user and administrator documentation baseline in every module manifest, even when richer configured-state topics come from `documentation_providers`. Run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after changing a manifest or module behavior.
- Treat Gitea issues as the canonical backlog and state log. Treat Gitea wiki pages as durable project context mirrored from repository and product docs. Use `docs/GITEA_ISSUES.md` for labels, templates, TODO import, wiki sync, and Codex issue updates.
- Do not keep generated WebUI test folders in git status; they should be ignored and removable.
- Do not start persistent dev servers unless the user asks.
@@ -0,0 +1,24 @@
"""development-track wrapper for runtime coordination and recovery."""
from __future__ import annotations
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
_path = (
Path(__file__).resolve().parents[1]
/ "versions"
/ "e14b8c2d6f90_runtime_coordination_recovery.py"
)
_spec = spec_from_file_location("govoplan_runtime_coordination_migration", _path)
if _spec is None or _spec.loader is None:
raise RuntimeError(f"Unable to load runtime coordination migration from {_path}")
_migration = module_from_spec(_spec)
_spec.loader.exec_module(_migration)
revision = _migration.revision
down_revision = _migration.down_revision
branch_labels = _migration.branch_labels
depends_on = _migration.depends_on
upgrade = _migration.upgrade
downgrade = _migration.downgrade
@@ -0,0 +1,232 @@
"""add runtime coordination and recovery evidence
Revision ID: e14b8c2d6f90
Revises: d03a7b9c1e5f
Create Date: 2026-08-01 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e14b8c2d6f90"
down_revision = "d03a7b9c1e5f"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "core_runtime_nodes" not in tables:
op.create_table(
"core_runtime_nodes",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("installation_id", sa.String(length=100), nullable=False),
sa.Column("node_id", sa.String(length=200), nullable=False),
sa.Column("incarnation", sa.String(length=36), nullable=False),
sa.Column("role", sa.String(length=40), nullable=False),
sa.Column("software_version", sa.String(length=80), nullable=False),
sa.Column("composition_hash", sa.String(length=64), nullable=False),
sa.Column("queues", sa.JSON(), nullable=False),
sa.Column("state", sa.String(length=30), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_heartbeat_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("drain_requested_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("drain_reason", sa.String(length=500), nullable=True),
sa.Column("stopped_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_runtime_nodes")),
sa.UniqueConstraint(
"installation_id",
"node_id",
name="uq_core_runtime_node_installation_node",
),
)
for column in (
"installation_id",
"node_id",
"incarnation",
"role",
"composition_hash",
"state",
):
op.create_index(
op.f(f"ix_core_runtime_nodes_{column}"),
"core_runtime_nodes",
[column],
unique=False,
)
op.create_index(
"ix_core_runtime_nodes_installation_state_heartbeat",
"core_runtime_nodes",
["installation_id", "state", "last_heartbeat_at"],
unique=False,
)
if "core_distributed_leases" not in tables:
op.create_table(
"core_distributed_leases",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("installation_id", sa.String(length=100), nullable=False),
sa.Column("resource_key", sa.String(length=255), nullable=False),
sa.Column("holder_node_id", sa.String(length=200), nullable=True),
sa.Column("holder_incarnation", sa.String(length=36), nullable=True),
sa.Column("fencing_token", sa.BigInteger(), nullable=False),
sa.Column("acquired_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("renewed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_distributed_leases")),
sa.UniqueConstraint(
"installation_id",
"resource_key",
name="uq_core_distributed_lease_resource",
),
)
for column in (
"installation_id",
"resource_key",
"holder_node_id",
"holder_incarnation",
):
op.create_index(
op.f(f"ix_core_distributed_leases_{column}"),
"core_distributed_leases",
[column],
unique=False,
)
op.create_index(
"ix_core_distributed_leases_expiry",
"core_distributed_leases",
["installation_id", "expires_at"],
unique=False,
)
if "core_recovery_operations" not in tables:
op.create_table(
"core_recovery_operations",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("installation_id", sa.String(length=100), nullable=False),
sa.Column("module_id", sa.String(length=100), nullable=False),
sa.Column("operation_type", sa.String(length=100), nullable=False),
sa.Column("resource_type", sa.String(length=100), nullable=True),
sa.Column("resource_id", sa.String(length=255), nullable=True),
sa.Column("mode", sa.String(length=40), nullable=False),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("idempotency_key", sa.String(length=200), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("plan", sa.JSON(), nullable=False),
sa.Column("backup_reference", sa.String(length=1000), nullable=True),
sa.Column("approval_reference", sa.String(length=1000), nullable=True),
sa.Column("lease_resource_key", sa.String(length=255), nullable=True),
sa.Column("holder_node_id", sa.String(length=200), nullable=True),
sa.Column("holder_incarnation", sa.String(length=36), nullable=True),
sa.Column("fencing_token", sa.BigInteger(), nullable=True),
sa.Column("checkpoint_count", sa.Integer(), nullable=False),
sa.Column("evidence_head_sha256", sa.String(length=64), nullable=True),
sa.Column("failure_summary", sa.Text(), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("recovery_started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("recovered_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_recovery_operations")),
sa.UniqueConstraint(
"installation_id",
"module_id",
"idempotency_key",
name="uq_core_recovery_operation_idempotency",
),
)
for column in (
"installation_id",
"module_id",
"operation_type",
"resource_type",
"resource_id",
"mode",
"status",
):
op.create_index(
op.f(f"ix_core_recovery_operations_{column}"),
"core_recovery_operations",
[column],
unique=False,
)
op.create_index(
"ix_core_recovery_operations_status_updated",
"core_recovery_operations",
["installation_id", "status", "updated_at"],
unique=False,
)
op.create_index(
"ix_core_recovery_operations_resource",
"core_recovery_operations",
["module_id", "resource_type", "resource_id"],
unique=False,
)
if "core_recovery_checkpoints" not in tables:
op.create_table(
"core_recovery_checkpoints",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("operation_id", sa.String(length=36), nullable=False),
sa.Column("sequence", sa.Integer(), nullable=False),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("kind", sa.String(length=80), nullable=False),
sa.Column("summary", sa.Text(), nullable=False),
sa.Column("evidence", sa.JSON(), nullable=False),
sa.Column("previous_sha256", sa.String(length=64), nullable=True),
sa.Column("checkpoint_sha256", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["operation_id"],
["core_recovery_operations.id"],
name=op.f(
"fk_core_recovery_checkpoints_operation_id_core_recovery_operations"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_recovery_checkpoints")),
sa.UniqueConstraint(
"operation_id",
"sequence",
name="uq_core_recovery_checkpoint_sequence",
),
)
for column in ("operation_id", "status", "checkpoint_sha256"):
op.create_index(
op.f(f"ix_core_recovery_checkpoints_{column}"),
"core_recovery_checkpoints",
[column],
unique=False,
)
op.create_index(
"ix_core_recovery_checkpoints_operation_created",
"core_recovery_checkpoints",
["operation_id", "created_at"],
unique=False,
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
for table in (
"core_recovery_checkpoints",
"core_recovery_operations",
"core_distributed_leases",
"core_runtime_nodes",
):
if table in tables:
op.drop_table(table)
+16
View File
@@ -99,6 +99,22 @@ The runner must never advance workflow state past a required side effect unless
the action definition explicitly allows asynchronous completion and the pending
state is visible.
For external and asynchronous effects, providers must preserve the distinction
between:
1. requested intent;
2. approved intent;
3. dispatched command;
4. possibly executed but unconfirmed outcome;
5. confirmed observed effect;
6. reconciled, corrected, or compensated outcome.
An API timeout after dispatch is not a failed effect and must not be retried as
a fresh command. The actor context should retain the real identity/account,
represented function or party, delegation or power, and mandate/jurisdiction
references when applicable. Domain modules remain responsible for deciding
which of those references are required for their action.
## Failure States
Automation should use explicit failure states:
+10
View File
@@ -49,6 +49,15 @@ The broad writable root reduces approval churn. The explicit project trust entri
Each active repository has an `AGENTS.md` file. These files define ownership, module boundaries, and focused commands for Codex. Keep durable project conventions there instead of repeating them in every prompt.
Documentation is part of the completion criteria for every behavior change. The
owning module must update its manifest-driven `DocumentationTopic` contributions
for affected user and administrator workflows, settings, permissions,
limitations, and operational consequences. Feature documentation remains in the
feature module; the optional `govoplan-docs` module projects those contributions
without importing feature internals. Every module manifest must retain a static
user and administrator baseline even when runtime providers add configured-state
details.
Use `~/.codex/config.toml` for personal defaults, auth/runtime settings, writable roots, and trust decisions. Avoid checking in absolute-path writable roots or model preferences unless they are intentionally team-wide.
Use Gitea issues as the canonical backlog and state log. See `docs/GITEA_ISSUES.md` for label setup, TODO import, and Codex issue update commands. Durable docs should describe stable behavior; changing work state belongs on the issue.
@@ -81,5 +90,6 @@ PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versi
- Avoid broad recursive scans and full builds unless the change warrants them.
- Keep generated build/test folders ignored.
- Keep optional module behavior behind core registry/capability/module metadata boundaries.
- Run `tools/checks/check-manifest-shapes.py` after module behavior or manifest changes so user/admin documentation coverage remains complete.
- Create or update Gitea issues for TODOs, follow-ups, blockers, and feature requests instead of keeping local backlog files.
- Do not start persistent dev servers unless the user asks.
+32
View File
@@ -48,6 +48,35 @@ interface = how configured parts connect
data = what the operator must provide for this deployment
```
## Package Classes
The same signed package mechanism supports several explicitly named classes:
| Class | Purpose |
| --- | --- |
| `reference` | Prove a bounded journey, degraded states, recovery, and target-environment acceptance. |
| `product` | Provide a reusable operating capability such as governed communication, service-to-decision, procurement/contracts, or governed BI. |
| `sector` | Specialize terminology, forms, rules, process baselines, controls, reports, and integrations for an institutional sector without forking modules. |
| `deployment` | Declare a supported infrastructure topology, operational assumptions, health gates, and recovery evidence. |
| `integration` | Declare external systems, provider bindings, source-authority modes, maturity, required health, and reconciliation behavior. |
Package class is metadata and validation context, not additional authority. A
sector package does not become a module and cannot write another module's
tables. Packages may extend other packages only through versioned fragments and
must preserve provenance and parent constraints.
The contract enforces class-specific evidence. Reference packages require
target, recovery, security, operations, accessibility, privacy, and
documentation evidence. Deployment and integration packages require their
corresponding target/recovery/operations evidence, while integration packages
also name provider authority and minimum-maturity expectations. Preflight
blocks a missing, incompatible, or unhealthy provider. A derived package may
tighten parent module, capability, and provider requirements but cannot remove
or loosen them. Every non-documentation claim made by reference, deployment, or
integration packages carries a `sha256:<digest>` binding. Repository checks
recompute those hashes, while signed package verification protects the declared
manifest during transport.
## Package Model
A configuration package should be a signed, portable manifest plus module-owned
@@ -68,6 +97,9 @@ Required package metadata:
- preflight checks and post-import health checks
- migration or transformation rules for older package versions
- provenance, export source metadata, and signature metadata
- package class and optional parent package/version constraints
- source-authority bindings and provider-operation expectations for every
external integration used by the package
Portable configuration schemas follow `COMPATIBILITY_POLICY.md`: providers
write only their current schema version and read that version plus the previous
+4
View File
@@ -15,7 +15,10 @@ operator, and roadmap pages.
| Policy decision DTOs and provenance | `POLICY_CONTRACTS.md` | Shared explain/provenance shape; module-specific policy docs should link here. |
| Events and audit trace context | `EVENTS_AND_AUDIT.md` | Event dispatch semantics and audit payload conventions. |
| Action/effect automation layer | `ACTION_EFFECT_AUTOMATION_LAYER.md` | Action/effect contracts, consequence preview, runner semantics, and module boundary for automation. |
| External references and integration maturity | `EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md` | Stable external identity and cumulative connector maturity; configured source authority is defined by the meta target architecture. |
| Institutional context and governed references | `INSTITUTIONAL_CONTEXT_CONTRACT.md` | Shared temporal, actor/representation, institution, mandate, service, party, decision, evidence, legal-basis, information-governance, presentation, and geo DTO/provider contracts. |
| Postbox E2EE target architecture | `POSTBOX_E2EE_ARCHITECTURE.md` | Strategic encrypted postbox/mailbox model, key ownership, role mailbox semantics, and retraction limits. |
| Shared state, runtime coordination, and recovery | `STATE_AND_RECOVERY_CONTRACT.md` | State profiles, object storage, node registration/drain, fenced leases, migration ordering, and recovery evidence. |
## Release And Operations
@@ -33,6 +36,7 @@ operator, and roadmap pages.
| Topic | Canonical document | Notes |
| --- | --- | --- |
| Product roadmap and module routing | `GOVOPLAN_MASTER_ROADMAP.md` | Product-level sequencing, implementation gates, issue routing, and missing-module decisions. |
| Institutional governance target | `govoplan/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md` | Cross-product semantic layers, source-authority modes, candidate Mandates/Services/Parties/Decisions boundaries, and migration sequence. |
| UI/UX decisions | `UI_UX_DECISION_LEDGER.md` | Binding guided-UI decisions, open decisions, impact index, and review checklist. |
| 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`. |
@@ -35,6 +35,26 @@ Connectors must declare and document the maturity they actually implement.
handling, deletion semantics, and observable failures. A link-only connector
must not imply that GovOPlaN holds an authoritative copy.
## Source Authority Is A Separate Dimension
Integration maturity states what an adapter is capable of doing. It does not
decide which system owns truth for a configured object or field group. A
binding separately selects one of the source-authority modes defined by the
[institutional governance target architecture](../../govoplan/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md):
- `native_authoritative`
- `external_authoritative`
- `external_mirror`
- `governed_sync`
- `governance_overlay`
- `linked_reference`
A connector can therefore support `synchronize` while a tenant deliberately
uses it only as an external mirror. Conversely, a native GovOPlaN object may
retain link-only references to several external systems. Authority may be
narrowed by tenant, organization, service, object type, object, field group, or
process step and must be visible in provenance and configuration preflight.
## Domain Ownership
- Domain modules own native GovOPlaN objects and their authorization.
+72 -57
View File
@@ -16,6 +16,9 @@ service and operating configurations, connected outcome stories, and
capability horizons. The selected five-stage delivery sequence and its gates
are in the meta repository's
[Reference Journey Program](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/REFERENCE_JOURNEY_PROGRAM.md).
The semantic target, source-authority modes, and reconciliation with the
implemented platform are in the meta repository's
[Institutional Governance Target Architecture](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
Those product documents are canonical; this Core roadmap remains their
technical sequencing and module-routing companion.
@@ -64,8 +67,9 @@ verify or reverse those effects.
`INTERFACE_ETHICS_AND_DESIGN_DOCTRINE.md`.
- Automation must use governed action/effect contracts, not hidden side
effects. The first automation layer is defined in
`ACTION_EFFECT_AUTOMATION_LAYER.md` and should start in `govoplan-workflow`
unless a separate automation module becomes justified.
`ACTION_EFFECT_AUTOMATION_LAYER.md`; its first runner lives in
`govoplan-workflow-engine`. Create a separate automation module only if the
scheduler/action runtime outgrows workflow coordination.
- Encrypted postboxes are a strategic target. Early postbox, access, and
identity-trust contracts should stay compatible with the E2EE architecture in
`POSTBOX_E2EE_ARCHITECTURE.md`.
@@ -144,8 +148,9 @@ pattern exists.
| Structured forms and validation | `govoplan-forms` |
| Uploaded files and managed storage | `govoplan-files` |
| Case record and lifecycle | `govoplan-cases` |
| Workflow transitions and automation | `govoplan-workflow` |
| Action/effect catalogue and automation runner | first `govoplan-workflow`; possible future `govoplan-automation` if it outgrows workflow |
| Workflow runtime, transitions, and automation | `govoplan-workflow-engine` |
| Workflow definition editing | optional `govoplan-workflow` |
| Action/effect catalogue and automation runner | first `govoplan-workflow-engine`; possible future `govoplan-automation` only if it outgrows workflow |
| Internal work queues and tasks | `govoplan-tasks` |
| Appointment proposals and booking | `govoplan-appointments`, `govoplan-calendar` |
| Postbox, email, and notifications | `govoplan-postbox`, `govoplan-mail`, `govoplan-notifications` |
@@ -153,13 +158,18 @@ pattern exists.
| Organizational structures, units, and functions | `govoplan-organizations` |
| Identity-to-function assignments and directory synchronization | `govoplan-idm` |
| Identity trust, device keys, and encrypted postbox key contracts | `govoplan-identity-trust`, `govoplan-access`, `govoplan-postbox` |
| Service directory/catalog | `govoplan-portal` |
| Service directory presentation | `govoplan-portal` |
| Versioned institutional service definition | shared service contract first; candidate `govoplan-services` after reuse proof |
| Mandate, jurisdiction, and institutional authority | shared mandate contract first; candidate `govoplan-mandates` after reuse proof |
| Procedure-local parties and representation | shared party contract first; candidate `govoplan-parties` after reuse proof |
| Formal institutional decisions | shared decision contract first; candidate `govoplan-decisions` after reuse proof |
| Permit/document generation | `govoplan-templates`, `govoplan-dms` |
| Payment capture and accounting handoff | `govoplan-payments`, `govoplan-ledger` |
| Authentication projection, roles, permissions, acting context | `govoplan-access` |
| Tenants, policy, and audit | `govoplan-tenancy`, `govoplan-policy`, `govoplan-audit` |
| External software integration | `govoplan-connectors` |
| Recurring extraction and transformation | possible future `govoplan-datasources`, possible future `govoplan-dataflow` |
| Governed data/register catalogue | `govoplan-datasources` |
| Recurring extraction and transformation | `govoplan-dataflow` over Datasources and Connectors contracts |
| Reports, BI, and management visibility | `govoplan-reporting` |
## Configuration And Safety Target
@@ -204,8 +214,9 @@ an editor applies a high-impact configuration change.
## Reference Journeys
The active sequence is selected. Workflow remains deliberately deferred and is
not a dependency of these journeys.
The active sequence is selected. Workflow Engine and its optional editor are
now available foundations, but a reference journey does not depend on Workflow
unless its package explicitly composes and proves it.
### Journey 1: Campaign Demonstration Composition
@@ -256,9 +267,11 @@ The result must preserve official-key mappings, organizational and reporting
date semantics, quality findings, quarantine/replay, transparent calculation,
and reproducible promotion between development, test, and production.
Reporting consumes the product. Create `govoplan-datasources` or
`govoplan-dataflow` only after the concrete path proves repeated ownership that
does not belong to connectors, Reporting, or the producing domain module.
Reporting consumes the product. Datasources owns the governed source and
materialization lifecycle; Dataflow owns typed transformation/run lineage;
Connectors owns external transport. The concrete path must now prove those
implemented boundaries and expose any missing contracts instead of recreating
them inside Reporting or a producing domain module.
### Journey 5: Collaborative Document Lifecycle
@@ -336,8 +349,9 @@ Create or refine in this order:
access.
4. `govoplan-cases`: case record, status, assignments, deadlines, and case
evidence.
5. `govoplan-workflow`: state machine, transitions, commands, and module
handoff.
5. `govoplan-workflow-engine`: state machine, transitions, commands, module
handoff, and resumable execution; optional `govoplan-workflow` supplies the
editor.
6. `govoplan-tasks`: work queues, assignments, due dates, and follow-ups.
7. `govoplan-templates`: permit/decision document generation.
8. `govoplan-postbox`, `govoplan-mail`, `govoplan-notifications`: applicant and
@@ -526,31 +540,34 @@ Refine:
dashboard data.
- `govoplan-search`: permissioned cross-module discovery.
Create only when justified:
Refine the existing owners:
- `govoplan-datasources`: source catalog, connection profiles, schema discovery,
freshness, provenance.
- `govoplan-dataflow`: transformations, validation, lineage, scheduled runs,
publication outputs.
- `govoplan-projects`: only if OpenProject connectors and existing cases/tasks
cannot cover the required semantics.
- `govoplan-datasources`: governed data/register catalog, live/cached/static
sources, staging, immutable materializations, freshness, quality, legal and
organizational context, and provenance. Connector profiles and credentials
remain in Connectors.
- `govoplan-dataflow`: typed transformations, validation, lineage, manual,
scheduled and event-triggered runs, reusable definitions, and publication
outputs.
- `govoplan-projects`: native projects, portfolios, milestones, goals,
dependencies, capacity, outcomes, and external OpenProject references;
Connectors owns OpenProject transport and synchronization.
Reference journey: monthly data extraction, transformation, validation, approval,
publication, and reporting.
Recurring extraction/transformation should start as a configuration package
across connectors, files, workflow, reporting, and templates. The package should
register sources, declare schemas, define mapping/validation versions, schedule
runs, produce previewable diffs, write governed outputs, and preserve lineage,
hashes, operator actions, and audit evidence. Create `govoplan-datasources` or
`govoplan-dataflow` only after this work exposes repeated contracts that do not
belong to existing modules.
Recurring extraction/transformation should be delivered as a configuration
package across Connectors, Datasources, Dataflow, Workflow Engine, Reporting,
Files, and Templates. The package should register sources, declare schemas,
define mapping/validation versions, schedule runs, produce previewable diffs,
write governed outputs, and preserve lineage, hashes, operator actions, and
audit evidence.
Exit criteria:
- connector catalog exists before building many adapters
- dataflow is created only after recurring transformation becomes product
behavior
- datasource and dataflow ownership remains provider-neutral and is proved by
the recurring transformation package
- reporting consumes governed sources with provenance
## Implementation Gates
@@ -591,23 +608,25 @@ in the capability waves:
4. Implement one data-backed Templates/Reporting path and safe HIS-style deep
launch.
5. Extend that concrete source into one governed university analytical data
product before generalizing data-source or dataflow ownership.
product and use it to harden the existing Datasources/Dataflow ownership,
quality, lineage, and promotion contracts.
6. Implement Files-backed DMS versions and one provider-neutral collaborative
editing lifecycle, then connect Records handoff.
7. Maintain already integrated Calendar/Scheduling/Poll and other foundations;
activate another capability cluster only when the current journey needs it
or the product roadmap explicitly reprioritizes it.
8. Resume Workflow only by explicit product decision and constrain it with
stable actions from one demonstrated package.
8. Extend Workflow Engine and the optional editor only through stable actions
and one demonstrated package at a time.
## Deliberate Deferrals
Defer these until a reference journey proves the need:
- full ERP replacement
- native project management beyond connector support
- an unbounded general-purpose dataflow platform; the bounded governed BI
reference journey is selected
- unsupported breadth in native project management before the Projects/OpenProject
boundary is proved in a reference journey
- unbounded Dataflow operators or execution engines without golden-flow,
quality, lineage, resource-limit, and recovery evidence
- every possible public-sector protocol adapter
- rich LMS behavior beyond training administration
- full qualified digital signing/trust services beyond the identity-trust and
@@ -630,26 +649,26 @@ repositories or to explicit missing-module decisions.
| Fully UI-managed configuration with safety controls | `govoplan-admin`, `govoplan-core`, `govoplan-policy`, `govoplan-access`, `govoplan-audit` | `GovOPlaN/govoplan-core#218` |
| Access as a module | `govoplan-access` | `GovOPlaN/govoplan-access#7` |
| Interface ethics and decision-consequence doctrine | `govoplan-core` plus all UI-owning modules | `GovOPlaN/govoplan-core#227` |
| Action/effect automation layer | first `govoplan-workflow`; possible future `govoplan-automation` | `GovOPlaN/govoplan-workflow#1` |
| Action/effect automation layer | `govoplan-workflow-engine`; possible future `govoplan-automation` only after boundary proof | `GovOPlaN/govoplan-workflow#1` |
| E2EE role/function postbox architecture | `govoplan-postbox`, `govoplan-identity-trust`, `govoplan-access`, `govoplan-policy`, `govoplan-audit` | `GovOPlaN/govoplan-postbox#15`, `GovOPlaN/govoplan-identity-trust#1` |
| Identity, account, function, role, right semantic model | `govoplan-access` | `GovOPlaN/govoplan-access#9` |
| Role-based service directory/catalog | `govoplan-portal` | `GovOPlaN/govoplan-portal#1` |
| Role-based service directory presentation | `govoplan-portal`; reusable service definition is a shared contract and candidate `govoplan-services` | `GovOPlaN/govoplan-portal#1` |
| Unified inbox across tasks, postbox, notifications, and portal | `govoplan-core` coordination plus owning modules | `GovOPlaN/govoplan-tasks#1`, `GovOPlaN/govoplan-notifications#1` |
| OpenProject API / project management connector | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#1` |
| Native project-management module decision | connector-first through `govoplan-connectors`; no native project module yet | `GovOPlaN/govoplan-core#196`, `GovOPlaN/govoplan-connectors#1` |
| Datasources for databases, CSV, files, APIs | no repository yet; start with connectors/files/reporting and create `govoplan-datasources` only after the first package proves shared source-catalog ownership | `GovOPlaN/govoplan-core#197` |
| Dataflow for pipelines, BI, publication | no repository yet; start with workflow/reporting/connectors and create `govoplan-dataflow` only after repeated pipeline/lineage contracts emerge | `GovOPlaN/govoplan-core#198` |
| Monthly datasource and transformation workflows | first as configuration package across connectors, files, workflow, reporting, and templates | `GovOPlaN/govoplan-core#216` |
| Native project-management module | `govoplan-projects`; OpenProject transport remains connector-owned | `GovOPlaN/govoplan-projects#1`, `GovOPlaN/govoplan-connectors#12` |
| Datasources for databases, CSV, files, APIs | `govoplan-datasources`, with external protocol and credential ownership in Connectors | `GovOPlaN/govoplan-datasources#1` |
| Dataflow for pipelines, BI, publication | `govoplan-dataflow` over Datasources and module-owned providers | `GovOPlaN/govoplan-dataflow#1` |
| Monthly datasource and transformation workflows | configuration package across Connectors, Datasources, Dataflow, Workflow Engine, Reporting, Files, and Templates | `GovOPlaN/govoplan#8`, `GovOPlaN/govoplan-dataflow#17` |
| Templates for letters, emails, forms, reports | `govoplan-templates`, separate from reporting | `GovOPlaN/govoplan-core#190`, `GovOPlaN/govoplan-templates#1` |
| Reporting and BI | `govoplan-reporting`, separate from templates | `GovOPlaN/govoplan-core#190`, `GovOPlaN/govoplan-reporting#1` |
| File connectors: Nextcloud, Seafile, SMB, NFS | `govoplan-files` | `GovOPlaN/govoplan-files#15` |
| Public-sector software integration catalogue | `govoplan-connectors` with core strategy index | `GovOPlaN/govoplan-core#191`, `GovOPlaN/govoplan-connectors#2` |
| Public-sector integration landscape catalogue | `govoplan-connectors` with core tracking | `GovOPlaN/govoplan-core#215` |
| Cases module concept | `govoplan-cases` | `GovOPlaN/govoplan-core#174` |
| Workflow module concept | `govoplan-workflow` | `GovOPlaN/govoplan-core#175` |
| Workflow runtime/editor split | `govoplan-workflow-engine` runtime plus optional `govoplan-workflow` editor | `GovOPlaN/govoplan-workflow#12`, `GovOPlaN/govoplan-workflow#13` |
| Connectors module concept | `govoplan-connectors` | `GovOPlaN/govoplan-core#176` |
| Adrema-style address and distribution-list management | `govoplan-addresses` | `GovOPlaN/govoplan-addresses#1` |
| Consume sources and become a governed source | `govoplan-connectors` plus possible future `govoplan-dataflow` | `GovOPlaN/govoplan-connectors#3`, `GovOPlaN/govoplan-core#198` |
| Consume sources and become a governed source | Connectors acquires, Datasources governs/materializes, Dataflow transforms/publishes | `GovOPlaN/govoplan-connectors#3`, `GovOPlaN/govoplan-datasources#3` |
| Governed connector configuration, dry-run, and simulation runtime | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#6` |
| Terminfindung and meeting scheduling polls | `govoplan-scheduling`; calendar primitives remain in calendar | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-scheduling#1` |
| Terminplaner and calendar primitives | `govoplan-calendar` | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-calendar#1` |
@@ -671,27 +690,23 @@ repositories or to explicit missing-module decisions.
Boundary rationale lives in `MODULE_ARCHITECTURE.md`. Current decisions:
- templates and reporting are separate modules
- RSS/source consume-publish starts in connectors; datasources/dataflow are not
repositories yet
- RSS/source consume-publish starts in Connectors; governed source identity and
snapshots belong to Datasources and transformations belong to Dataflow
- calendar, scheduling, and appointments are three separate modules
- forms definitions and forms runtime are separate responsibilities
- OpenDesk is an integration profile across modules, not a monolithic module
- OpenProject is connector-first; no native projects module yet
- OpenProject transport is connector-owned; native portfolio/project semantics
belong to Projects
- public-sector integration strategy stays in core; executable catalogue work
lives in connectors
- encrypted postbox and identity-trust are strategic contracts, not mail-module
behavior
- automation starts as workflow-owned action/effect execution and may split into
a dedicated module only after the runner becomes broader than workflow
The following modules are intentionally not created yet:
- `govoplan-datasources`
- `govoplan-dataflow`
- `govoplan-projects`
Create a repository only after a concrete implementation package proves that
existing connector, files, reporting, workflow, or task ownership is too narrow.
- automation starts in Workflow Engine and may split into a dedicated module
only after the runner becomes broader than workflow
- Mandates, Services, Parties, and Decisions begin as shared semantic contracts;
create repositories only after independent persistence, lifecycle, security,
and multiple-consumer evidence passes the repository threshold in the
institutional governance target architecture
Core keeps the strategy index in
`PUBLIC_SECTOR_INTEGRATION_STRATEGY.md`: integration postures, default
+159
View File
@@ -0,0 +1,159 @@
# Institutional Context And Governed References
GovOPlaN consequential work must retain enough context to answer who acted,
for whom, through which function, under which mandate and jurisdiction, using
which rule and evidence versions, and with which requested and observed effect.
The shared contract lives in `govoplan_core.core.institutional`.
Core owns reference shapes and provider protocols only. It does not own shared
Mandate, Service, Party, Decision, evidence, or geography tables. Domain modules
own persistence and authorization; optional capabilities resolve the references.
## Envelope
`GovernedContextEnvelope` version 1 carries:
- a tenant and `TemporalRevision` with validity, recording, supersession, and
change reason;
- the real account or service account and represented account, function,
procedure party, assignment, delegation/power, and mandate;
- institution, organization unit, function, task, mandate, jurisdiction,
service, case, party, work item, workflow, approval, decision, and record
references;
- versioned legal bases and evidence references;
- information classification, purposes, retention/holds, minimization, and
disclosure state;
- external-source authority, maturity, freshness, health, and conflict state;
- language, accessibility, channel, explanation, and availability references.
Every institutional reference includes the owner module, tenant, stable object
identity, optional version/effective instant, and a protected display label.
Cross-tenant references are rejected. Safe serialization omits labels,
inspection URLs, formal reasoning, operative results, and conditions unless a
caller explicitly requests the protected projection.
## Semantic Providers
The first provider-neutral capabilities are:
- `mandates.resolver`: resolve competence for a task/authority type at an
effective instant and return the governing Mandate definition and evidence;
- `services.definitions`: obtain versioned institutional service definitions;
- `parties.resolver`: obtain effective procedure-local parties and powers of
representation without copying Identity or Organizations subjects; and
- `decisions.registry`: record and retrieve formal Decisions under optimistic
revision control.
The Mandate, Service, Party/representation, and Decision DTOs have strict
mapping round-trips so they can cross capability, event, package, and storage
boundaries without shared ORM models. Their lifecycle states are explicit:
Mandates distinguish draft/active/suspended/replaced/retired, Services retain
publication state, Parties retain effective representation and revocation, and
Decisions retain correction, revocation, and supersession references.
The DTOs are a repository threshold, not a mandate to create four modules.
Independent persistence, lifecycle, security/operations behavior, release
reason, reuse, and tests are still required before extraction.
Mandate resolution is deterministic: Core filters candidates by tenant,
effective interval, active state, task and authority type, stable
organization/function identity, jurisdiction coverage, and subject type. A
result is competent only when exactly one matching Mandate remains and it has
no unresolved conflicts. Evidence from matching definitions is deduplicated
and retained in the explanation result. `revise_mandate_definition` applies
optimistic concurrency and the allowed activation, suspension, replacement,
and retirement transitions while leaving the previous revision immutable.
`revise_formal_decision` provides the equivalent lifecycle primitive for
formal outcomes. Every accepted transition requires a new recorded revision
and change reason, links `supersedes_ref` to the prior version, updates the
authority envelope to the new version, and records explicit correction or
revocation provenance. Terminal and backward transitions fail closed. Each
Decision also records whether responsibility was human, human-reviewed
automation, or an automated service account acting under mandate. Automation
preparation/recommendation references remain inspectable without being
mistaken for the responsible outcome.
Procedure-party corrections use `revise_procedure_party`: the stable party
identity is retained, a new revision and reason are required, stale writes are
rejected, and revoked/expired/superseded assignments are terminal.
`revoke_party_representation` separately records when a limited power ceased
to authorize actions. This allows consuming procedures to evaluate historical
delivery or representation authority without rewriting Identity,
Organizations, or Addresses records.
Service templates and package/tenant specializations use
`derive_service_restriction`. The derived definition retains an explicit
parent-version reference, cannot extend the parent's validity, audience,
channels, or publication ceiling, and cannot remove inherited prerequisites,
required evidence, legal bases, or bindings. This is the fail-closed semantic
rule; configuration-package signature and provenance checks remain the package
transport rule.
`ServiceAvailabilityRequirement` represents module, capability, mandate,
policy, connector, maintenance, audience, and configuration prerequisites with
an explicit unavailable-or-hidden failure mode and explanation reference. The
optional `services.availability` evaluator returns policy-scoped boolean
assessments, reason codes, and evidence. Unknown consequential requirements
fail closed; a reference itself never grants access.
## Service Launch
`ServiceLaunchRequest` and `ServiceLaunchResult` define the owner-neutral
boundary between Portal entry and a case, form, or workflow runtime effect.
The request carries the exact published Service definition, exact selected
binding, tenant, acting identity, timezone-aware request time, bounded
parameters, and idempotency key. The result must retain that exact Service and
binding, a same-tenant target reference, optional same-tenant evidence, and
only a relative or credential-free HTTP(S) destination.
`service_launch_capability(kind)` maps bindings to owner capabilities:
- `case` -> `cases.service_launcher`
- `form` -> `forms_runtime.service_launcher`
- `workflow` -> `workflow_engine.service_launcher`
`FormDefinition`, `FormFieldDefinition`, and `forms.definitions` provide the
owner-neutral exact-schema boundary used by Forms Runtime. Definitions carry an
exact tenant/revision, field types/options/constraints/defaults, publication,
draft, attachment, signature, policy, and handoff requirements. Forms owns
those immutable definitions; Forms Runtime persists instances and validation
evidence. A form Service binding uses `<form-id>/<revision>` and the launcher
rejects missing, superseded, unpublished, cross-tenant, or invalid definitions.
Portal may discover and invoke those capabilities but cannot write owner
tables. The owner must revalidate its definition/binding and current
authorization, produce its normal audit/event state, and make replay after an
ambiguous response safe. If the capability is absent, the service is
explainably unavailable. URL-only entries pass through the same launch-time
availability check and destination validation. Forms Runtime now supplies the
definition-aware form launcher when both Forms and Forms Runtime are active;
otherwise Portal continues to fail closed.
## Propagation
`PlatformEvent`, `ActionExecutionRequest`, and `AuditEvent` can carry the
envelope. Audit persistence stores only its safe projection; platform-event
outbox serialization preserves it across asynchronous delivery. A module must
not invent a parallel context dictionary when the shared fields apply.
## First Proof
Committee's `committee.decision_path` capability is the first bounded proof. It
requires one effective, conflict-free Mandate covering the organization unit
function, and jurisdiction, an approval reference, fact evidence, versioned legal bases,
operative result, and reasoning. It emits a reconstructable `FormalDecision`,
including requested/observed effects and information governance. If a Decision
registry is installed it persists there; Committee does not take ownership of
the generic Decision lifecycle.
## Compatibility And Security
- Contract version changes follow Core compatibility policy.
- Unknown tenant or reference-kind combinations fail closed.
- Datetimes that affect authority must be timezone-aware.
- Protected labels, reasoning, evidence inspection links, and source details
remain subject to the owning module's access policy.
- References do not grant access to their targets.
- Evidence and audit payloads must contain stable references/checksums, not
plaintext secrets.
+149 -1
View File
@@ -13,6 +13,10 @@ Policy decision, source provenance, and explain-response contracts are tracked
in [`POLICY_CONTRACTS.md`](POLICY_CONTRACTS.md).
The experimental remote WebUI bundle loading design is tracked in
[`REMOTE_WEBUI_BUNDLES.md`](REMOTE_WEBUI_BUNDLES.md).
The cross-product semantic layers, source-authority modes, and candidate
Mandates, Services, Parties, and Decisions boundaries are canonical in the
meta repository's
[`INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md`](../../govoplan/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
## Layer Model
@@ -24,6 +28,36 @@ The experimental remote WebUI bundle loading design is tracked in
| Business modules | Public-sector workflows | campaigns, cases, forms, approvals, appointments |
| Connector modules | External system integration | FIT-Connect, XÖV/XTA, DMS/eAkte, ERP, IDM |
This table is the technical composition model. The product portfolio uses a
more detailed institutional layer model, but it does not change dependency
direction: Core provides contracts and composition; modules own semantics;
packages compose modules.
## Institutional Semantic Boundaries
Cross-module references must keep these answers distinct:
- Organizations owns where structures, units, and functions exist.
- Identity owns who a subject is; Access owns accounts, roles, permissions,
and authorization decisions; IDM owns effective function assignments.
- A Mandates capability will answer why a unit or function is competent for a
task, jurisdiction, subject, or period. It must not become another RBAC
system.
- A Services capability will own versioned institutional service definitions;
Portal presents and starts them.
- A Parties capability will own procedure-local participant roles,
representation, and delivery authority; it must reference rather than copy
Identity, Organizations, and Addresses subjects.
- A Decisions capability will own formal institutional outcomes and their
authority, facts, rules, reasoning, effects, correction, and review.
Approvals owns review gates, Committee owns deliberation/votes, and Workflow
Engine owns coordination.
Start each missing concept as a versioned DTO/provider contract used by a
bounded journey. A repository is justified only when the concept gains
independent persistence, lifecycle, security/operations behavior, release
reason, and reuse. Core must not store these domain objects.
## Kernel Responsibilities
The kernel target owns:
@@ -98,6 +132,11 @@ The following contracts are the baseline API that modules can rely on:
- navigation metadata contract
- command/event envelope contract
- policy decision and source provenance contract in `govoplan_core.core.policy`
- external object reference and integration-maturity contract in
`govoplan_core.core.external_references`
- action/effect preview and execution contract in
`govoplan_core.core.automation`
- workflow definition contribution and runtime-worker contracts
Changes to these contracts must be versioned or accompanied by compatibility shims.
@@ -115,6 +154,30 @@ may extend the kernel by adding explicit contracts, but existing contracts must
remain source-compatible through the 0.1.x split line unless a migration shim
and deprecation note are provided.
### Architecture Metadata
`ModuleManifest.architecture` is the backward-compatible, versioned product-
portfolio declaration for:
- module kind and institutional architecture layer;
- evidence-backed maturity claim (`concept`, `scaffold`, `vertical_slice`,
`reference_ready`, `supported`, or `lts`);
- owned and explicitly non-owned concepts;
- supported source-authority modes;
- reference packages, tested providers, and known limits;
- migration, upgrade, recovery, security, operations, and documentation
evidence references.
Core validates the claim and all provider references during registry startup.
`reference_ready`, `supported`, and `lts` claims require a named reference
package and the cumulative evidence set; target-tested providers additionally
require provider evidence. A supported module with migrations must include
migration evidence. Signed release catalogs retain and revalidate the
declaration. The meta manifest check validates repository evidence paths and
provides an opt-in `--require-architecture` rollout gate. Platform metadata,
Docs, and Ops project the same declaration. A module cannot make itself
supported solely by changing its maturity string.
Known access-related capability names are defined in
`govoplan_core.core.access`, including:
@@ -180,6 +243,45 @@ intended for SemVer major-version lines. Missing optional interfaces are
allowed, but an installed provider with an incompatible version blocks
activation because the integration would otherwise bind to an unsafe API.
### Source Authority And Provider Operations
Integration maturity and configured authority are independent. The existing
external-reference maturity ladder describes whether an adapter can discover,
link, search, read, publish, synchronize, migrate, or replace. A binding must
also state whether GovOPlaN is native authoritative, the external system is
authoritative, GovOPlaN keeps a mirror, both sides use governed sync, GovOPlaN
adds only a governance overlay, or the object is link-only.
`ModuleManifest.external_providers` composes existing contracts rather than
replacing them. Each declaration describes owned object/field groups, authority modes,
operations, revisions, freshness, health, limits, idempotency, conflicts,
outcome-unknown handling, evidence, correction/compensation, reconciliation,
outage behavior, classification, purpose, retention, and secret requirements.
Core owns the typed declaration and validation. Connectors and domain modules
own the actual protocol and domain behavior; configuration packages select the
effective mode and block missing/incompatible/unhealthy providers; Docs and Ops
explain the result. Effect-capable declarations fail validation unless their
retry, concurrency, outcome-unknown, correction, reconciliation, evidence,
audit, timeout, outage, classification, purpose, retention, and secret behavior
is explicit.
Declarations are release-time capability claims. Configured state is projected
separately through `ModuleManifest.external_provider_state_providers`. A state
provider receives a bounded tenant context and returns one sanitized observation
per configured binding: stable binding reference, effective authority mode,
active/configured state, health, freshness, conflict, recovery readiness,
observation/last-success time, and scalar metrics. Core validates and aggregates
those observations, isolates provider failures, and never accepts URLs,
credentials, or arbitrary nested metadata as runtime-state fields. Docs removes
binding-level detail from ordinary-user projections; Ops may show the full
sanitized operator projection.
Configuration-package preflight selects the exact requested binding from this
runtime state before evaluating authority, health, freshness, and recovery. A
healthy sibling binding therefore cannot mask an unhealthy required binding.
Providers with multiple configurations must use non-secret, stable references
such as `calendar:sync-source:<id>`.
Current named interfaces, generated from the source manifests by the workspace
contract checks, are:
@@ -593,6 +695,39 @@ Rules:
one migration run, and do not switch a database between tracks unless it is a
disposable development database.
### Shared State And Runtime Ordering
Multi-host application roles use the `shared` state profile. In that profile,
PostgreSQL, Redis, a stable installation identifier, and S3-compatible object
storage are mandatory. Module durable artifacts must use Core's object-storage
contract and module-owned opaque key namespaces; node-local paths are limited
to temporary materialization. Same-host replicas may use the `host-shared`
profile and one shared volume.
Only the migration command mutates schema. PostgreSQL migration runs acquire a
deployment-wide advisory lock before module pre-tasks, Alembic, and post-tasks.
API, worker, and scheduler roles wait for exact configured migration heads and
fail closed instead of applying migrations during startup.
Runtime roles register identity, software/module composition, queues, heartbeat,
and drain state in PostgreSQL. Singleton work must use a distributed lease and
validate its monotonically increasing fencing token at the consequential
commit. See `STATE_AND_RECOVERY_CONTRACT.md` for the complete contract.
### Recovery Evidence
Operations spanning transactions, object storage, queues, or external systems
must choose an explicit Core recovery mode: atomic, compensation,
snapshot-restore, forward-recovery, or irreversible. Plans require verification
steps and mode-specific recovery material. Use idempotency keys, append-only
evidence checkpoints, and a runtime fence where work may race across nodes.
The recovery ledger is a shared primitive, not automatic coverage. A module may
claim its guarantees only after its operation records preconditions before side
effects, transitions partial/unknown outcomes honestly, and records verified
completion or recovery. Plaintext secrets must never enter recovery metadata or
evidence.
## Install, Uninstall, And Catalogs
Core owns the install plan, signed catalog validation, license entitlement
@@ -962,7 +1097,7 @@ from workflow semantics.
- form definitions, schemas, validation rules, field visibility rules,
localization, versioning, admin editing, and reusable form package fragments
`govoplan-forms-runtime` owns, when implemented:
`govoplan-forms-runtime` owns:
- public/internal submissions, drafts, submitted values, validation evidence,
attachment references, submission receipts, and handoff events
@@ -975,6 +1110,16 @@ Boundary:
- Reporting/dataflow may consume submitted data through governed DTOs or
source lifecycle contracts.
Implemented contract:
- Core owns the provider-neutral `FormDefinition`/`FormFieldDefinition` DTOs.
- Forms persists immutable exact definitions and provides `forms.definitions`.
- Forms Runtime resolves that capability, persists revisioned instances and
events, validates draft/final values, and provides
`forms_runtime.service_launcher`.
- Portal delegates exact `<form-id>/<revision>` bindings and never writes either
owner's tables.
### OpenDesk Integration Profile
Tracking: `govoplan-core#195`, `govoplan-connectors#5`,
@@ -1219,6 +1364,9 @@ the same restart/health set after restoring package and database snapshots.
The installer preflight is intentionally conservative:
- maintenance mode must be active;
- the `shared` state profile blocks in-place package mutation; clustered
installations must roll one verified immutable module composition across all
replicas;
- installed module manifests must be compatible with the supported manifest
contract and current core version;
- uninstalling `tenancy`, `access`, or `admin` is blocked;
+4 -2
View File
@@ -175,8 +175,10 @@ connector or module issue.
queries, untraceable manual transformations.
- MVP test path: publish one report/export as a governed file plus RSS/Atom
entry with checksum, timestamp, and permission check.
- Owner/priority: `govoplan-reporting`, `govoplan-connectors`, possible future
`govoplan-datasources`/`govoplan-dataflow`, Wave 2.
- Owner/priority: `govoplan-reporting`, `govoplan-connectors`,
`govoplan-datasources`, and `govoplan-dataflow`, Wave 2. Reporting owns
presentation/publication, Connectors owns transport, Datasources owns the
governed source/materialization catalogue, and Dataflow owns transformations.
### Public-Sector Protocols And Registries
+143
View File
@@ -0,0 +1,143 @@
# State And Recovery Contract
## State Profiles
Core accepts three runtime state profiles:
| Profile | Runtime placement | Durable storage |
| --- | --- | --- |
| `local` | One development process set | Local filesystem is permitted. |
| `host-shared` | Replicas on one host | One host-shared volume plus PostgreSQL and Redis. |
| `shared` | Independent hosts | PostgreSQL, Redis, and S3-compatible object storage. |
All replicas in one installation use one stable
`GOVOPLAN_INSTALLATION_ID`, one immutable module composition, and identical
database, broker, encryption-key, and object-storage bindings. Core rejects
replicas with the `local` profile and rejects `shared` without PostgreSQL,
Redis, S3, and a non-default installation identifier.
`FILE_STORAGE_S3_ENDPOINT_TRUSTED=true` is an explicit deployment trust
declaration for a clean HTTPS S3 origin. It does not authorize a
user-controlled connector endpoint and it is separate from installer-managed
Garage's exact endpoint trust.
## Object Storage
`govoplan_core.core.object_storage` is the shared backend contract for durable
module artifacts. It provides bounded read/write/list/stat/delete operations
for local and S3-compatible storage. Modules own their object-key namespace and
business metadata; Core does not interpret module files.
Rules for modules:
- Store only opaque object keys in business records, never local absolute
paths.
- Use node-local directories only for temporary materialization.
- Verify expected size and digest before consuming consequential artifacts.
- If object creation precedes database commit, compensate successfully created
objects on failure.
- If object deletion fails, retain the database reference and report a retryable
failure rather than claiming deletion.
- Define an orphan-inventory strategy for hard process loss between object
creation and metadata commit.
The Files module delegates its backend implementation to this Core contract.
Campaign generated EML artifacts use a Campaign-owned object prefix and are
read by workers through the same shared backend.
## Runtime Nodes And Leases
API and worker incarnations register in `core_runtime_nodes` with role,
software version, module-composition hash, queues, start time, and heartbeat.
The registration identity includes a process incarnation so a stale process
cannot update a replacement's row.
Drain is durable operator intent:
- an API enters not-ready state after observing drain;
- a worker cancels queue consumers after observing drain;
- cancellation returns an eligible draining node to active state;
- clean shutdown marks the matching incarnation stopped.
Coordination loss also fails closed. An API reports
`coordination_unavailable` from `/health/ready` until a heartbeat succeeds
again. A worker cancels its local queue consumers on any heartbeat or database
failure and only resumes them after its existing incarnation heartbeats
successfully. It never re-registers from the heartbeat path, so a stale worker
cannot reclaim a node identity from its replacement.
`core_distributed_leases` provides installation/resource uniqueness, expiry,
holder incarnation, and monotonically increasing fencing tokens. Lease expiry
does not make an old process harmless by itself: code performing an effect must
assert its exact fence before commit. `govoplan_core.commands.fenced_run`
renews a lease around a subprocess and terminates the child when the lease is
lost. The deployment profiles use it for the singleton scheduler.
## Migration Ordering
Only `govoplan_core.commands.init_db` mutates schema. On PostgreSQL it acquires
a deterministic installation/track advisory lock before pre-migration tasks,
Alembic, and post-migration tasks. The lock is session-scoped and therefore
released if the migration process dies.
Runtime roles run `govoplan_core.commands.wait_for_database`. It waits until
the database has exactly the configured Core/module Alembic heads and never
upgrades schema. This permits a migration Job and runtime Deployments to be
submitted together while keeping startup fail-closed.
## Recovery Ledger
`govoplan_core.core.recovery` provides a durable operation and evidence
contract. Recovery modes are:
- `atomic`: one database transaction, no external effect;
- `compensation`: explicit inverse actions;
- `snapshot_restore`: separately verified backup reference;
- `forward_recovery`: repair/resume the current version;
- `irreversible`: explicit approval, no automated recovery claim.
Every plan requires verification steps. Mode-specific evidence is mandatory.
Operations bind an idempotency key to a canonical request hash, may bind a
runtime fencing token, and emit append-only hash-chained checkpoints. Backup
and approval references are part of the hashed plan evidence. Every low-level
state transition and checkpoint append revalidates the operation's recorded
fence while holding the operation row lock. Plaintext secrets are rejected
from metadata and evidence.
The state machine makes partial and uncertain outcomes visible. A non-atomic
running operation cannot transition directly to ordinary failure, and success
or recovery requires explicit verified checks. Ops projects states requiring
attention, but module behavior gains this guarantee only after it adopts the
ledger around its own side effects.
## Recovery Boundary
Application/configuration rollback and database rollback are not equivalent.
Once an incompatible migration starts, old code may be unsafe even if its image
is available. Deployment automation must switch to forward recovery unless a
coordinated and verified database/object/key backup is restored.
Core does not create production database backups. The deployment owner must
provide backup, retention, encryption, restore verification, and recovery-point
coordination for PostgreSQL, object storage, and encryption keys. The canonical
operator procedure is documented in
`govoplan/docs/RECOVERY_AND_ROLLBACK_GUARANTEES.md`.
## Verification
Focused contracts are covered by:
```sh
/mnt/DATA/git/govoplan/.venv/bin/python -m pytest -q \
tests/test_object_storage.py \
tests/test_runtime_coordination.py \
tests/test_runtime_agents.py \
tests/test_fenced_run.py \
tests/test_migration_lock.py \
tests/test_wait_for_database.py \
tests/test_recovery_guarantees.py
```
Production acceptance additionally requires multi-node failure and coordinated
restore drills against the actual PostgreSQL, Redis, object-store, ingress, and
secret-provider topology.
+1
View File
@@ -19,6 +19,7 @@ dependencies = [
"celery>=5,<6",
"redis>=5,<6",
"alembic>=1,<2",
"boto3>=1.34,<2",
]
[tool.setuptools.packages.find]
+26 -1
View File
@@ -18,6 +18,7 @@ from govoplan_core.core.events import (
normalize_trace_id,
emit_platform_event,
)
from govoplan_core.core.institutional import GovernedContextEnvelope
from govoplan_core.core.runtime import get_registry
from govoplan_core.privacy.retention import sanitize_audit_details_for_policy
from govoplan_core.security.redaction import redact_secret_values
@@ -176,6 +177,16 @@ def _publish_audit_platform_event(
item: AuditRecordRef,
) -> None:
trace = _compact_trace(item.details.get("_trace") if isinstance(item.details, Mapping) else None)
raw_context = (
item.details.get("_institutional_context")
if isinstance(item.details, Mapping)
else None
)
institutional_context = (
GovernedContextEnvelope.from_mapping(raw_context)
if isinstance(raw_context, Mapping)
else None
)
emit_platform_event(
session,
PlatformEvent(
@@ -194,6 +205,7 @@ def _publish_audit_platform_event(
tenant=EventTenantRef(id=item.tenant_id) if item.tenant_id else None,
resource=EventObjectRef(type=item.object_type, id=item.object_id) if item.object_type else None,
classification="internal",
institutional_context=institutional_context,
)
)
@@ -211,6 +223,7 @@ def audit_event(
details: dict[str, Any] | None = None,
correlation_id: str | None = None,
causation_id: str | None = None,
institutional_context: GovernedContextEnvelope | None = None,
commit: bool = False,
) -> AuditRecordRef:
"""Persist one audit event.
@@ -223,8 +236,17 @@ def audit_event(
if scope not in {"tenant", "system"}:
raise ValueError(f"Unsupported audit scope: {scope}")
if (
institutional_context is not None
and tenant_id is not None
and institutional_context.tenant_id != tenant_id
):
raise ValueError("Audit institutional context belongs to another tenant")
raw_details = dict(details or {})
if institutional_context is not None:
raw_details["_institutional_context"] = institutional_context.to_dict()
traced_details, trace = _trace_details(
_sanitize_details(details or {}),
_sanitize_details(raw_details),
correlation_id=correlation_id,
causation_id=causation_id,
)
@@ -242,6 +264,7 @@ def audit_event(
api_key_id=api_key_id,
resource_type=object_type,
resource_id=object_id,
institutional_context=institutional_context,
details=stored_details,
))
record_change(
@@ -273,6 +296,7 @@ def audit_from_principal(
details: dict[str, Any] | None = None,
correlation_id: str | None = None,
causation_id: str | None = None,
institutional_context: GovernedContextEnvelope | None = None,
commit: bool = False,
) -> AuditRecordRef:
return audit_event(
@@ -287,5 +311,6 @@ def audit_from_principal(
details=details,
correlation_id=correlation_id,
causation_id=causation_id,
institutional_context=institutional_context,
commit=commit,
)
+182 -23
View File
@@ -1,11 +1,20 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from importlib.metadata import PackageNotFoundError, version
import logging
from celery import Celery
from celery.signals import heartbeat_sent, worker_ready, worker_shutdown
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_OUTBOX, CalendarOutboxProvider
from govoplan_core.core.campaigns import (
CAPABILITY_CAMPAIGNS_DELIVERY_TASKS,
CampaignDeliveryTaskProvider,
)
from govoplan_core.core.calendar import (
CAPABILITY_CALENDAR_OUTBOX,
CalendarOutboxProvider,
)
from govoplan_core.core.dataflows import (
CAPABILITY_DATAFLOW_RUN_WORKER,
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER,
@@ -23,7 +32,10 @@ from govoplan_core.core.idm import (
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE,
IdmAssignmentLifecycle,
)
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.module_management import (
load_startup_enabled_modules,
startup_candidate_module_ids,
)
from govoplan_core.core.mail import (
CAPABILITY_MAIL_BOUNCE_PROCESSING,
CAPABILITY_MAIL_DELIVERY_OUTBOX,
@@ -31,7 +43,10 @@ from govoplan_core.core.mail import (
MailDeliveryOutboxProvider,
)
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
from govoplan_core.core.notifications import (
CAPABILITY_NOTIFICATIONS_DISPATCH,
NotificationDispatchProvider,
)
from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_ROUTING,
PostboxRoutingProvider,
@@ -42,9 +57,19 @@ from govoplan_core.core.workflows import (
)
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import configure_runtime
from govoplan_core.core.runtime_coordination import (
RuntimeIdentity,
heartbeat_runtime_node,
register_runtime_node,
runtime_identity,
stop_runtime_node,
)
from govoplan_core.settings import settings
from govoplan_core.db.session import configure_database
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.db.session import configure_database, get_database
from govoplan_core.server.registry import (
available_module_manifests,
build_platform_registry,
)
configure_database(settings.database_url)
@@ -141,6 +166,119 @@ celery.conf.update(
},
)
_worker_identity: RuntimeIdentity | None = None
_worker_draining = False
_worker_consumer: object | None = None
logger = logging.getLogger("govoplan.worker.runtime")
def _core_version() -> str:
try:
return version("govoplan-core")
except PackageNotFoundError:
return "development"
def _worker_runtime_identity(sender: object | None = None) -> RuntimeIdentity:
global _worker_identity
if _worker_identity is None:
hostname = str(getattr(sender, "hostname", "") or "").strip() or None
module_ids = tuple(load_startup_enabled_modules(settings.enabled_modules))
_worker_identity = runtime_identity(
settings,
software_version=_core_version(),
module_ids=module_ids,
role="worker",
node_id=hostname,
)
return _worker_identity
@worker_ready.connect
def _register_worker_runtime(sender=None, **_kwargs) -> None:
global _worker_consumer
_worker_consumer = sender
identity = _worker_runtime_identity(sender)
try:
with get_database().SessionLocal() as session:
node = register_runtime_node(
session,
identity,
metadata={"process": "celery-worker"},
)
session.commit()
except Exception:
_set_worker_consumers(identity, draining=True)
logger.exception(
"worker runtime registration failed; queues were disabled node_id=%s",
identity.node_id,
)
raise
_set_worker_consumers(identity, draining=node.state == "draining")
@heartbeat_sent.connect
def _heartbeat_worker_runtime(sender=None, **_kwargs) -> None:
identity = _worker_runtime_identity(sender)
try:
with get_database().SessionLocal() as session:
node = heartbeat_runtime_node(
session,
identity,
metadata={"process": "celery-worker"},
)
session.commit()
except Exception: # noqa: BLE001 - uncertain authority must fail closed
_set_worker_consumers(identity, draining=True)
logger.exception(
"worker runtime heartbeat failed; queues were disabled node_id=%s",
identity.node_id,
)
return
_set_worker_consumers(identity, draining=node.state == "draining")
def _set_worker_consumers(
identity: RuntimeIdentity,
*,
draining: bool,
) -> None:
global _worker_draining
if draining == _worker_draining:
return
consumer = _worker_consumer
if consumer is not None:
method_name = "cancel_task_queue" if draining else "add_task_queue"
method = getattr(consumer, method_name)
destination = None
else:
method = (
celery.control.cancel_consumer if draining else celery.control.add_consumer
)
destination = [identity.node_id]
for queue in identity.queues:
if destination is None:
method(queue)
else:
method(queue, destination=destination)
_worker_draining = draining
@worker_shutdown.connect
def _stop_worker_runtime(sender=None, **_kwargs) -> None:
identity = _worker_identity
if identity is None:
return
try:
with get_database().SessionLocal() as session:
stop_runtime_node(session, identity)
session.commit()
except Exception: # noqa: BLE001 - shutdown must continue
logger.exception(
"worker runtime stop marker failed node_id=%s",
identity.node_id,
)
@celery.task(name="govoplan.ping")
def ping():
@@ -149,9 +287,15 @@ def ping():
def _platform_registry() -> PlatformRegistry:
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules)
available_modules = available_module_manifests(enabled_modules=candidate_modules, ignore_load_errors=True)
enabled_modules = load_startup_enabled_modules(settings.enabled_modules, available=available_modules)
candidate_modules = startup_candidate_module_ids(
settings.enabled_modules, raw_enabled_modules
)
available_modules = available_module_manifests(
enabled_modules=candidate_modules, ignore_load_errors=True
)
enabled_modules = load_startup_enabled_modules(
settings.enabled_modules, available=available_modules
)
registry = build_platform_registry(enabled_modules)
context = ModuleContext(registry=registry, settings=settings)
configure_runtime(context)
@@ -211,9 +355,7 @@ def _dataflow_trigger_dispatcher(
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER):
return None
capability = registry.require_capability(
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER
)
capability = registry.require_capability(CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER)
if not isinstance(capability, DataflowTriggerDispatcher):
raise RuntimeError("Dataflow trigger dispatcher capability is invalid")
return capability
@@ -237,9 +379,7 @@ def _workflow_runtime_worker(
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_WORKFLOW_RUNTIME_WORKER):
return None
capability = registry.require_capability(
CAPABILITY_WORKFLOW_RUNTIME_WORKER
)
capability = registry.require_capability(CAPABILITY_WORKFLOW_RUNTIME_WORKER)
if not isinstance(capability, WorkflowRuntimeWorker):
raise RuntimeError("Workflow runtime worker capability is invalid")
return capability
@@ -275,9 +415,7 @@ def _idm_assignment_lifecycle(
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE):
return None
capability = registry.require_capability(
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE
)
capability = registry.require_capability(CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE)
if not isinstance(capability, IdmAssignmentLifecycle):
raise RuntimeError("IDM assignment lifecycle capability is invalid")
return capability
@@ -295,7 +433,11 @@ def send_email(self, job_id: str):
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
return dict(_campaign_delivery_tasks().send_campaign_job(session, job_id=job_id, enqueue_imap_task=True))
return dict(
_campaign_delivery_tasks().send_campaign_job(
session, job_id=job_id, enqueue_imap_task=True
)
)
@celery.task(name="govoplan.campaigns.append_sent", bind=True, max_retries=None)
@@ -306,7 +448,9 @@ def append_sent(self, job_id: str):
with get_database().SessionLocal() as session:
try:
return dict(_campaign_delivery_tasks().append_sent_for_job(session, job_id=job_id))
return dict(
_campaign_delivery_tasks().append_sent_for_job(session, job_id=job_id)
)
except Exception as exc:
if getattr(exc, "temporary", None) is True:
raise self.retry(exc=exc, countdown=300)
@@ -318,7 +462,11 @@ def deliver_notification(self, notification_id: str):
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
result = dict(_notification_dispatch().deliver_notification(session, notification_id=notification_id))
result = dict(
_notification_dispatch().deliver_notification(
session, notification_id=notification_id
)
)
session.commit()
return result
@@ -328,7 +476,11 @@ def deliver_pending_notifications(self, tenant_id: str | None = None, limit: int
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
result = dict(_notification_dispatch().deliver_pending(session, tenant_id=tenant_id, limit=limit))
result = dict(
_notification_dispatch().deliver_pending(
session, tenant_id=tenant_id, limit=limit
)
)
session.commit()
return result
@@ -411,7 +563,13 @@ def dispatch_calendar_outbox(self, tenant_id: str | None = None, limit: int = 50
with get_database().SessionLocal() as session:
provider = _calendar_outbox()
if provider is None:
return {"processed": 0, "succeeded": 0, "retrying": 0, "failed": 0, "operations": []}
return {
"processed": 0,
"succeeded": 0,
"retrying": 0,
"failed": 0,
"operations": [],
}
result = dict(provider.dispatch_due(session, tenant_id=tenant_id, limit=limit))
session.commit()
return result
@@ -608,6 +766,7 @@ def dispatch_platform_events(self, limit: int = 100):
dataflow_dispatcher = _dataflow_trigger_dispatcher(registry)
consumers = ()
if dataflow_dispatcher is not None:
def deliver_to_dataflow(
event: PlatformEvent,
_delivery_key: str,
+199
View File
@@ -0,0 +1,199 @@
from __future__ import annotations
import argparse
from importlib.metadata import PackageNotFoundError, version
import signal
import subprocess
import sys
import threading
import time
from typing import Sequence
from govoplan_core.core.runtime_coordination import (
LeaseClaim,
acquire_lease,
release_lease,
renew_lease,
runtime_identity,
)
from govoplan_core.db.session import configure_database, get_database
from govoplan_core.settings import settings
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Run one process while holding a database-fenced deployment lease."
)
parser.add_argument(
"--resource", required=True, help="Stable cluster-wide lease key."
)
parser.add_argument("--ttl-seconds", type=int, default=60)
parser.add_argument("--renew-seconds", type=int, default=15)
parser.add_argument("--wait-seconds", type=int, default=0)
parser.add_argument("command", nargs=argparse.REMAINDER)
return parser
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
command = list(args.command)
if command and command[0] == "--":
command.pop(0)
if not command:
raise SystemExit("fenced-run requires a command after --")
if args.ttl_seconds < 10:
raise SystemExit("--ttl-seconds must be at least 10")
if args.renew_seconds < 2 or args.renew_seconds * 2 >= args.ttl_seconds:
raise SystemExit("--renew-seconds must be less than half the lease TTL")
configure_database(settings.database_url)
identity = runtime_identity(
settings,
software_version=_core_version(),
role=str(settings.runtime_role or "deployment"),
)
claim = _wait_for_lease(
resource=args.resource,
identity=identity,
ttl_seconds=args.ttl_seconds,
wait_seconds=max(0, args.wait_seconds),
)
if claim is None:
print(
f"lease unavailable: {args.resource}",
file=sys.stderr,
)
return 75
process = subprocess.Popen(command) # noqa: S603 - argv is an operator-owned container command
stop = threading.Event()
fence_lost = threading.Event()
renewer = threading.Thread(
target=_renew_loop,
kwargs={
"claim": claim,
"ttl_seconds": args.ttl_seconds,
"renew_seconds": args.renew_seconds,
"stop": stop,
"fence_lost": fence_lost,
"process": process,
},
daemon=True,
name=f"govoplan-fence:{args.resource}",
)
renewer.start()
previous_handlers = _forward_signals(process)
try:
return_code = process.wait()
finally:
stop.set()
renewer.join(timeout=args.renew_seconds + 2)
_restore_signals(previous_handlers)
_release(claim)
if fence_lost.is_set():
return 74
return int(return_code)
def _wait_for_lease(
*,
resource: str,
identity,
ttl_seconds: int,
wait_seconds: int,
) -> LeaseClaim | None:
deadline = time.monotonic() + wait_seconds
while True:
with get_database().SessionLocal() as session:
claim = acquire_lease(
session,
installation_id=identity.installation_id,
resource_key=resource,
holder_node_id=identity.node_id,
holder_incarnation=identity.incarnation,
ttl_seconds=ttl_seconds,
metadata={"role": identity.role},
)
session.commit()
if claim is not None or time.monotonic() >= deadline:
return claim
time.sleep(min(2, max(0.1, deadline - time.monotonic())))
def _renew_loop(
*,
claim: LeaseClaim,
ttl_seconds: int,
renew_seconds: int,
stop: threading.Event,
fence_lost: threading.Event,
process: subprocess.Popen[bytes],
) -> None:
active_claim = claim
while not stop.wait(renew_seconds):
try:
with get_database().SessionLocal() as session:
active_claim = renew_lease(
session,
active_claim,
ttl_seconds=ttl_seconds,
)
session.commit()
except Exception: # noqa: BLE001 - any renewal failure loses authority
fence_lost.set()
_terminate_process(process)
return
def _terminate_process(
process: subprocess.Popen[bytes],
*,
timeout_seconds: float = 5.0,
) -> None:
if process.poll() is not None:
return
process.terminate()
try:
process.wait(timeout=timeout_seconds)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=timeout_seconds)
def _release(claim: LeaseClaim) -> None:
try:
with get_database().SessionLocal() as session:
release_lease(session, claim)
session.commit()
except Exception: # noqa: BLE001 - authority is already lost; release is best effort
return
def _forward_signals(
process: subprocess.Popen[bytes],
) -> dict[int, signal.Handlers]:
previous: dict[int, signal.Handlers] = {}
def forward(signum, _frame) -> None:
if process.poll() is None:
process.send_signal(signum)
for signum in (signal.SIGTERM, signal.SIGINT):
previous[signum] = signal.getsignal(signum)
signal.signal(signum, forward)
return previous
def _restore_signals(previous: dict[int, signal.Handlers]) -> None:
for signum, handler in previous.items():
signal.signal(signum, handler)
def _core_version() -> str:
try:
return version("govoplan-core")
except PackageNotFoundError:
return "development"
if __name__ == "__main__":
raise SystemExit(main())
+13
View File
@@ -12,6 +12,7 @@ from govoplan_core.db.migrations import (
migrate_database,
run_registered_module_migration_tasks,
)
from govoplan_core.db.migration_lock import deployment_migration_lock
from govoplan_core.db.session import configure_database, get_database
from govoplan_core.settings import settings
@@ -28,6 +29,12 @@ def main() -> None:
parser.add_argument("--enabled-module", action="append", default=[], help="Target enabled module id used to discover module migrations; may be repeated.")
parser.add_argument("--migration-module", action="append", default=[], help="Module id whose migration heads should be upgraded in this order before final heads.")
parser.add_argument("--migration-task-record-output", type=Path, help="Write executed module migration task records to this JSON file.")
parser.add_argument(
"--migration-lock-timeout-seconds",
type=float,
default=900.0,
help="Maximum wait for the deployment-wide PostgreSQL advisory lock.",
)
parser.add_argument("--with-dev-data", action="store_true", help="Create default tenant/user/roles and a development API key")
parser.add_argument("--dev-api-key", default=settings.dev_bootstrap_api_key, help="Development API key secret to create")
args = parser.parse_args()
@@ -37,6 +44,12 @@ def main() -> None:
migration_order = tuple(args.migration_module) if args.migration_module else None
task_records: list[dict[str, object]] = []
try:
with deployment_migration_lock(
args.database_url,
installation_id=settings.installation_id,
migration_track=args.migration_track,
timeout_seconds=args.migration_lock_timeout_seconds,
):
_run_migration_tasks(
task_records,
database_url=args.database_url,
@@ -0,0 +1,61 @@
from __future__ import annotations
import argparse
import sys
import time
from govoplan_core.db.migrations import (
configured_migration_heads,
database_migration_heads,
)
from govoplan_core.settings import settings
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Wait until the database is at this release's migration heads."
)
parser.add_argument("--database-url", default=settings.database_url)
parser.add_argument(
"--migration-track",
default=settings.migration_track,
choices=("release", "dev"),
)
parser.add_argument("--timeout-seconds", type=float, default=900.0)
parser.add_argument("--poll-seconds", type=float, default=2.0)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.timeout_seconds < 0 or args.poll_seconds <= 0:
raise SystemExit("timeouts must be non-negative and polling must be positive")
expected = configured_migration_heads(
args.database_url,
migration_track=args.migration_track,
)
deadline = time.monotonic() + args.timeout_seconds
last_error = ""
while True:
try:
actual = database_migration_heads(args.database_url)
if actual == expected:
print("Database migration heads are ready: " + ",".join(actual))
return 0
last_error = (
f"database heads={','.join(actual) or '<none>'}; "
f"expected={','.join(expected) or '<none>'}"
)
except Exception as exc: # noqa: BLE001 - connection may become ready later
last_error = f"{type(exc).__name__}: {exc}"
if time.monotonic() >= deadline:
print(
"Database did not reach configured migration heads: " + last_error,
file=sys.stderr,
)
return 75
time.sleep(min(args.poll_seconds, max(0.05, deadline - time.monotonic())))
if __name__ == "__main__":
raise SystemExit(main())
+2
View File
@@ -6,6 +6,7 @@ from datetime import datetime
from typing import Literal, Protocol, cast, runtime_checkable
from govoplan_core.core.modules import AccessDecision
from govoplan_core.core.institutional import GovernedContextEnvelope
ACCESS_MODULE_ID = "access"
@@ -377,6 +378,7 @@ class AuditEvent:
occurred_at: datetime | None = None
correlation_id: str | None = None
causation_id: str | None = None
institutional_context: GovernedContextEnvelope | None = None
details: Mapping[str, object] = field(default_factory=dict)
+2
View File
@@ -8,6 +8,7 @@ from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.access import (
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
)
from govoplan_core.core.institutional import GovernedContextEnvelope
AutomationInvocationKind = Literal[
"manual",
@@ -110,6 +111,7 @@ class ActionExecutionRequest:
input: Mapping[str, object]
idempotency_key: str
invocation: AutomationInvocation
institutional_context: GovernedContextEnvelope | None = None
actor_ref: str | None = None
preview_ref: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@@ -7,7 +7,8 @@ from datetime import UTC, datetime
from pathlib import Path
import json
import os
from typing import Any, Literal, Protocol, runtime_checkable
import re
from typing import Any, Literal, Protocol, cast, runtime_checkable
from govoplan_core.core.module_package_catalog import (
_canonical_catalog_bytes,
@@ -21,6 +22,12 @@ from govoplan_core.core.module_package_catalog import (
_load_private_key,
_parse_trusted_keys,
)
from govoplan_core.core.external_references import (
IntegrationMaturity,
SOURCE_AUTHORITY_MODES,
SourceAuthorityMode,
integration_maturity_rank,
)
from govoplan_core.security.http_fetch import fetch_http_text
@@ -28,6 +35,166 @@ CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider"
DiagnosticSeverity = Literal["blocker", "warning", "info"]
PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"]
ConfigurationPackageClass = Literal[
"reference",
"product",
"sector",
"deployment",
"integration",
]
ConfigurationPackageEvidenceKind = Literal[
"target_test",
"migration",
"upgrade",
"recovery",
"security",
"operations",
"accessibility",
"privacy",
"documentation",
]
CONFIGURATION_PACKAGE_CLASSES: tuple[ConfigurationPackageClass, ...] = (
"reference",
"product",
"sector",
"deployment",
"integration",
)
CONFIGURATION_PACKAGE_EVIDENCE_KINDS: tuple[
ConfigurationPackageEvidenceKind, ...
] = (
"target_test",
"migration",
"upgrade",
"recovery",
"security",
"operations",
"accessibility",
"privacy",
"documentation",
)
_SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
@dataclass(frozen=True, slots=True)
class ConfigurationPackageParent:
package_id: str
version: str
relation: Literal["derived_from", "specializes", "extends"] = "derived_from"
def __post_init__(self) -> None:
if not self.package_id.strip() or not self.version.strip():
raise ValueError("Configuration package parent id and version are required.")
if self.relation not in {"derived_from", "specializes", "extends"}:
raise ValueError(
f"Unsupported configuration package parent relation: {self.relation!r}."
)
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationPackageParent":
relation = _optional_str(value, "relation") or "derived_from"
if relation not in {"derived_from", "specializes", "extends"}:
raise ValueError(f"Unsupported configuration package parent relation: {relation!r}.")
return cls(
package_id=_required_str(value, "package_id"),
version=_required_str(value, "version"),
relation=relation,
)
def to_dict(self) -> dict[str, str]:
return {
"package_id": self.package_id,
"version": self.version,
"relation": self.relation,
}
@dataclass(frozen=True, slots=True)
class ConfigurationPackageEvidence:
kind: ConfigurationPackageEvidenceKind
reference: str
summary: str
checksum: str | None = None
def __post_init__(self) -> None:
if self.kind not in CONFIGURATION_PACKAGE_EVIDENCE_KINDS:
raise ValueError(
f"Unsupported configuration package evidence kind: {self.kind!r}."
)
if not self.reference.strip() or not self.summary.strip():
raise ValueError(
"Configuration package evidence reference and summary are required."
)
if self.checksum is not None and not _SHA256_RE.fullmatch(self.checksum):
raise ValueError(
"Configuration package evidence checksum must use sha256:<64 lowercase hex>."
)
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationPackageEvidence":
kind = _required_str(value, "kind")
if kind not in CONFIGURATION_PACKAGE_EVIDENCE_KINDS:
raise ValueError(f"Unsupported configuration package evidence kind: {kind!r}.")
return cls(
kind=kind,
reference=_required_str(value, "reference"),
summary=_required_str(value, "summary"),
checksum=_optional_str(value, "checksum"),
)
def to_dict(self) -> dict[str, object]:
return {
"kind": self.kind,
"reference": self.reference,
"summary": self.summary,
"checksum": self.checksum,
}
@dataclass(frozen=True, slots=True)
class ConfigurationProviderExpectation:
provider_id: str
authority_mode: SourceAuthorityMode
minimum_maturity: IntegrationMaturity
binding_ref: str | None = None
health_expectation: str = "healthy"
freshness_expectation: str | None = None
recovery_expectation: str | None = None
def __post_init__(self) -> None:
if not self.provider_id.strip():
raise ValueError("Configuration provider expectation id is required.")
if self.authority_mode not in SOURCE_AUTHORITY_MODES:
raise ValueError(
f"Unsupported provider authority mode: {self.authority_mode!r}."
)
integration_maturity_rank(self.minimum_maturity)
if not self.health_expectation.strip():
raise ValueError("Provider health expectation is required.")
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationProviderExpectation":
return cls(
provider_id=_required_str(value, "provider_id"),
authority_mode=_required_str(value, "authority_mode"),
minimum_maturity=_required_str(value, "minimum_maturity"),
binding_ref=_optional_str(value, "binding_ref"),
health_expectation=_optional_str(value, "health_expectation") or "healthy",
freshness_expectation=_optional_str(value, "freshness_expectation"),
recovery_expectation=_optional_str(value, "recovery_expectation"),
)
def to_dict(self) -> dict[str, object]:
return {
"provider_id": self.provider_id,
"authority_mode": self.authority_mode,
"minimum_maturity": self.minimum_maturity,
"binding_ref": self.binding_ref,
"health_expectation": self.health_expectation,
"freshness_expectation": self.freshness_expectation,
"recovery_expectation": self.recovery_expectation,
}
@dataclass(frozen=True, slots=True)
@@ -75,6 +242,7 @@ class ConfigurationPackageManifest:
package_id: str
name: str
version: str
package_class: ConfigurationPackageClass = "product"
description: str | None = None
publisher: str | None = None
category: str | None = None
@@ -88,6 +256,29 @@ class ConfigurationPackageManifest:
artifact_ref: str | None = None
artifact_sha256: str | None = None
signature: Mapping[str, Any] | None = None
parents: tuple[ConfigurationPackageParent, ...] = ()
evidence: tuple[ConfigurationPackageEvidence, ...] = ()
provider_expectations: tuple[ConfigurationProviderExpectation, ...] = ()
def __post_init__(self) -> None:
if self.package_class not in CONFIGURATION_PACKAGE_CLASSES:
raise ValueError(
f"Unsupported configuration package class: {self.package_class!r}."
)
parent_keys = {(item.package_id, item.version) for item in self.parents}
if len(parent_keys) != len(self.parents):
raise ValueError("Configuration package parents must be unique.")
evidence_keys = {(item.kind, item.reference) for item in self.evidence}
if len(evidence_keys) != len(self.evidence):
raise ValueError("Configuration package evidence must be unique.")
provider_ids = [item.provider_id for item in self.provider_expectations]
if len(provider_ids) != len(set(provider_ids)):
raise ValueError("Configuration package provider expectations must be unique.")
for expectation in self.provider_expectations:
integration_maturity_rank(expectation.minimum_maturity)
issues = configuration_package_claim_issues(self)
if issues:
raise ValueError("Invalid configuration package claim: " + "; ".join(issues))
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationPackageManifest":
@@ -95,6 +286,7 @@ class ConfigurationPackageManifest:
package_id=_required_str(value, "package_id"),
name=_required_str(value, "name"),
version=_required_str(value, "version"),
package_class=_optional_str(value, "package_class") or "product",
description=_optional_str(value, "description"),
publisher=_optional_str(value, "publisher"),
category=_optional_str(value, "category"),
@@ -108,6 +300,21 @@ class ConfigurationPackageManifest:
artifact_ref=_optional_str(value, "artifact_ref"),
artifact_sha256=_optional_str(value, "artifact_sha256"),
signature=value.get("signature") if isinstance(value.get("signature"), Mapping) else None,
parents=tuple(
ConfigurationPackageParent.from_mapping(item)
for item in _object_list(value.get("parents"), field_name="parents")
),
evidence=tuple(
ConfigurationPackageEvidence.from_mapping(item)
for item in _object_list(value.get("evidence"), field_name="evidence")
),
provider_expectations=tuple(
ConfigurationProviderExpectation.from_mapping(item)
for item in _object_list(
value.get("provider_expectations"),
field_name="provider_expectations",
)
),
)
def to_dict(self) -> dict[str, object]:
@@ -115,12 +322,18 @@ class ConfigurationPackageManifest:
"package_id": self.package_id,
"name": self.name,
"version": self.version,
"package_class": self.package_class,
"required_modules": [item.to_dict() for item in self.required_modules],
"required_capabilities": list(self.required_capabilities),
"optional_modules": [item.to_dict() for item in self.optional_modules],
"fragments": [item.to_dict() for item in self.fragments],
"data_requirements": [dict(item) for item in self.data_requirements],
"tags": list(self.tags),
"parents": [item.to_dict() for item in self.parents],
"evidence": [item.to_dict() for item in self.evidence],
"provider_expectations": [
item.to_dict() for item in self.provider_expectations
],
}
for key, value in (
("description", self.description),
@@ -221,6 +434,12 @@ class ConfigurationPreflightContext:
supplied_data: Mapping[str, Any] = field(default_factory=dict)
installed_modules: Mapping[str, str] = field(default_factory=dict)
capabilities: frozenset[str] = frozenset()
external_provider_declarations: Mapping[str, Mapping[str, Any]] = field(
default_factory=dict
)
external_provider_states: Mapping[str, Mapping[str, Any]] = field(
default_factory=dict
)
dry_run: bool = True
@@ -286,6 +505,7 @@ def dry_run_configuration_package(
diagnostics.extend(_module_requirement_diagnostics(manifest, context))
diagnostics.extend(_capability_requirement_diagnostics(manifest, context))
diagnostics.extend(_provider_expectation_diagnostics(manifest, context))
for item in manifest.data_requirements:
requirement = ConfigurationRequiredData.from_mapping(item)
required_data.append(requirement)
@@ -369,6 +589,8 @@ def apply_configuration_package(
supplied_data=supplied_data if supplied_data is not None else context.supplied_data,
installed_modules=context.installed_modules,
capabilities=context.capabilities,
external_provider_declarations=context.external_provider_declarations,
external_provider_states=context.external_provider_states,
dry_run=False,
)
preflight = dry_run_configuration_package(manifest, providers, apply_context)
@@ -665,6 +887,154 @@ def record_configuration_package_catalog_acceptance(validation: dict[str, object
state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def configuration_package_claim_issues(
manifest: ConfigurationPackageManifest,
) -> tuple[str, ...]:
evidence_kinds = {item.kind for item in manifest.evidence}
required_evidence: dict[str, frozenset[str]] = {
"reference": frozenset(
{
"target_test",
"recovery",
"security",
"operations",
"accessibility",
"privacy",
"documentation",
}
),
"product": frozenset(),
"sector": frozenset({"documentation"}),
"deployment": frozenset(
{"target_test", "recovery", "security", "operations"}
),
"integration": frozenset(
{"target_test", "recovery", "operations", "documentation"}
),
}
issues: list[str] = []
missing = sorted(required_evidence[manifest.package_class] - evidence_kinds)
if missing:
issues.append(
f"{manifest.package_class} package is missing evidence: "
+ ", ".join(missing)
)
if manifest.package_class in {"reference", "deployment", "integration"}:
unbound = sorted(
item.kind
for item in manifest.evidence
if item.kind != "documentation" and item.checksum is None
)
if unbound:
issues.append(
f"{manifest.package_class} package has evidence without checksums: "
+ ", ".join(unbound)
)
if manifest.package_class == "sector" and not manifest.parents:
issues.append("sector packages must declare a parent package/version")
if manifest.package_class == "integration" and not manifest.provider_expectations:
issues.append("integration packages must declare external provider expectations")
return tuple(issues)
def validate_configuration_package_derivation(
child: ConfigurationPackageManifest,
parent: ConfigurationPackageManifest,
) -> tuple[ConfigurationDiagnostic, ...]:
diagnostics: list[ConfigurationDiagnostic] = []
if not any(
item.package_id == parent.package_id and item.version == parent.version
for item in child.parents
):
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="package_parent_provenance_missing",
message=(
f"Package {child.package_id!r} does not declare parent "
f"{parent.package_id}@{parent.version}."
),
object_ref=parent.package_id,
)
)
child_modules = {item.module_id: item for item in child.required_modules}
for requirement in parent.required_modules:
candidate = child_modules.get(requirement.module_id)
if candidate is None or (
requirement.version is not None
and candidate.version != requirement.version
):
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="package_parent_module_constraint_loosened",
message=(
f"Derived package loosens parent module requirement "
f"{requirement.module_id!r}."
),
module_id=requirement.module_id,
)
)
for capability in set(parent.required_capabilities) - set(
child.required_capabilities
):
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="package_parent_capability_constraint_loosened",
message=(
f"Derived package removes required capability {capability!r}."
),
object_ref=capability,
)
)
child_providers = {
item.provider_id: item for item in child.provider_expectations
}
for expectation in parent.provider_expectations:
candidate = child_providers.get(expectation.provider_id)
if candidate is None:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="package_parent_provider_constraint_removed",
message=(
f"Derived package removes provider expectation "
f"{expectation.provider_id!r}."
),
object_ref=expectation.provider_id,
)
)
continue
if candidate.authority_mode != expectation.authority_mode:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="package_parent_authority_mode_changed",
message=(
f"Derived package changes authority mode for provider "
f"{expectation.provider_id!r}."
),
object_ref=expectation.provider_id,
)
)
if integration_maturity_rank(
candidate.minimum_maturity
) < integration_maturity_rank(expectation.minimum_maturity):
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="package_parent_provider_maturity_loosened",
message=(
f"Derived package lowers provider maturity for "
f"{expectation.provider_id!r}."
),
object_ref=expectation.provider_id,
)
)
return tuple(diagnostics)
def _configuration_package_manifest(package: ConfigurationPackageManifest | Mapping[str, Any]) -> ConfigurationPackageManifest:
if isinstance(package, ConfigurationPackageManifest):
return package
@@ -716,6 +1086,194 @@ def _capability_requirement_diagnostics(manifest: ConfigurationPackageManifest,
return diagnostics
def _provider_expectation_diagnostics(
manifest: ConfigurationPackageManifest,
context: ConfigurationPreflightContext,
) -> list[ConfigurationDiagnostic]:
diagnostics: list[ConfigurationDiagnostic] = []
for expectation in manifest.provider_expectations:
declaration = context.external_provider_declarations.get(
expectation.provider_id
)
if declaration is None:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="external_provider_missing",
message=(
f"Required external provider {expectation.provider_id!r} "
"is not installed or declared."
),
object_ref=expectation.provider_id,
resolution=(
"Install and enable a module exposing the declared provider."
),
)
)
continue
supported_modes = {
str(item)
for item in declaration.get("authority_modes", ())
if str(item).strip()
}
if expectation.authority_mode not in supported_modes:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="external_provider_authority_mode_unsupported",
message=(
f"Provider {expectation.provider_id!r} does not support "
f"authority mode {expectation.authority_mode!r}."
),
object_ref=expectation.provider_id,
)
)
actual_maturity = str(declaration.get("maturity") or "discover")
try:
maturity_sufficient = integration_maturity_rank(
cast(IntegrationMaturity, actual_maturity)
) >= integration_maturity_rank(expectation.minimum_maturity)
except ValueError:
maturity_sufficient = False
if not maturity_sufficient:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="external_provider_maturity_insufficient",
message=(
f"Provider {expectation.provider_id!r} has maturity "
f"{actual_maturity!r}; {expectation.minimum_maturity!r} is required."
),
object_ref=expectation.provider_id,
)
)
provider_state = context.external_provider_states.get(
expectation.provider_id
)
if provider_state is None:
diagnostics.append(
ConfigurationDiagnostic(
severity="warning",
code="external_provider_health_unverified",
message=(
f"Provider {expectation.provider_id!r} has no current "
"health/freshness observation."
),
object_ref=expectation.provider_id,
)
)
continue
state = provider_state
if expectation.binding_ref is not None:
bindings = provider_state.get("bindings")
matching_binding = next(
(
item
for item in bindings
if isinstance(item, Mapping)
and str(item.get("binding_ref") or "")
== expectation.binding_ref
),
None,
) if isinstance(bindings, Sequence) and not isinstance(
bindings, (str, bytes)
) else None
if matching_binding is None and str(
provider_state.get("binding_ref") or ""
) == expectation.binding_ref:
matching_binding = provider_state
if matching_binding is None:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="external_provider_binding_mismatch",
message=(
f"Provider {expectation.provider_id!r} is not observed through "
f"required binding {expectation.binding_ref!r}."
),
object_ref=expectation.provider_id,
)
)
continue
state = matching_binding
health = str(state.get("health") or state.get("health_state") or "unknown")
accepted_health = (
{"ok", "healthy"}
if expectation.health_expectation == "healthy"
else {expectation.health_expectation}
)
if health not in accepted_health:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="external_provider_unhealthy",
message=(
f"Provider {expectation.provider_id!r} health is {health!r}."
),
object_ref=expectation.provider_id,
resolution="Restore provider health or use a documented degraded path.",
)
)
observed_authority_mode = str(state.get("authority_mode") or "")
if (
observed_authority_mode
and observed_authority_mode != expectation.authority_mode
):
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="external_provider_binding_authority_mismatch",
message=(
f"Provider {expectation.provider_id!r} is configured as "
f"{observed_authority_mode!r}; {expectation.authority_mode!r} "
"is required."
),
object_ref=expectation.provider_id,
)
)
if expectation.freshness_expectation is not None:
freshness = str(
state.get("freshness")
or state.get("freshness_state")
or "unknown"
)
if freshness != expectation.freshness_expectation:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="external_provider_freshness_expectation_failed",
message=(
f"Provider {expectation.provider_id!r} freshness is "
f"{freshness!r}; {expectation.freshness_expectation!r} is required."
),
object_ref=expectation.provider_id,
)
)
if expectation.recovery_expectation is not None:
behavior = declaration.get("behavior")
declared_recovery = (
behavior.get(expectation.recovery_expectation)
if isinstance(behavior, Mapping)
else None
)
observed_recovery = state.get("recovery") or state.get(
"recovery_state"
)
if not declared_recovery and observed_recovery != expectation.recovery_expectation:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="external_provider_recovery_expectation_failed",
message=(
f"Provider {expectation.provider_id!r} does not satisfy "
f"recovery expectation {expectation.recovery_expectation!r}."
),
object_ref=expectation.provider_id,
)
)
return diagnostics
def _dedupe_diagnostics(items: Sequence[ConfigurationDiagnostic]) -> list[ConfigurationDiagnostic]:
seen: set[tuple[object, ...]] = set()
result: list[ConfigurationDiagnostic] = []
+179
View File
@@ -5,6 +5,11 @@ from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.external_references import (
SOURCE_AUTHORITY_MODES,
SourceAuthorityMode,
)
CAPABILITY_DATASOURCE_CATALOGUE = "datasources.catalogue"
CAPABILITY_DATASOURCE_LIFECYCLE = "datasources.lifecycle"
@@ -53,6 +58,137 @@ class DatasourceField:
nullable: bool = True
@dataclass(frozen=True, slots=True)
class DatasourceGovernance:
"""Provider-neutral governance facts attached to a datasource revision."""
owner_ref: str | None = None
steward_ref: str | None = None
responsible_organization_ref: str | None = None
responsible_function_ref: str | None = None
authoritative_source_ref: str | None = None
authority_mode: SourceAuthorityMode = "linked_reference"
legal_basis_refs: tuple[str, ...] = ()
purposes: tuple[str, ...] = ()
semantic_definition: str | None = None
schema_owner_ref: str | None = None
official_keys: tuple[str, ...] = ()
classification: str = "internal"
privacy_profile_ref: str | None = None
retention_policy_ref: str | None = None
hold_refs: tuple[str, ...] = ()
publication_state: str = "draft"
transfer_agreement_ref: str | None = None
freshness_policy: Mapping[str, object] = field(default_factory=dict)
quality_policy: Mapping[str, object] = field(default_factory=dict)
known_limits: tuple[str, ...] = ()
correction_procedure_ref: str | None = None
affected_refs: tuple[str, ...] = ()
dependency_refs: tuple[str, ...] = ()
def __post_init__(self) -> None:
if self.authority_mode not in SOURCE_AUTHORITY_MODES:
raise DatasourceValidationError(
f"Unsupported datasource authority mode: {self.authority_mode!r}."
)
if not self.classification.strip():
raise DatasourceValidationError("Datasource classification is required.")
if not self.publication_state.strip():
raise DatasourceValidationError("Datasource publication state is required.")
for field_name in (
"legal_basis_refs",
"purposes",
"official_keys",
"hold_refs",
"known_limits",
"affected_refs",
"dependency_refs",
):
values = getattr(self, field_name)
if any(not value.strip() for value in values):
raise DatasourceValidationError(
f"Datasource governance {field_name} cannot contain empty values."
)
if len(values) != len(set(values)):
raise DatasourceValidationError(
f"Datasource governance {field_name} cannot contain duplicates."
)
@classmethod
def from_mapping(cls, value: Mapping[str, object] | None) -> "DatasourceGovernance":
source = value or {}
return cls(
owner_ref=_optional_governance_text(source.get("owner_ref")),
steward_ref=_optional_governance_text(source.get("steward_ref")),
responsible_organization_ref=_optional_governance_text(
source.get("responsible_organization_ref")
),
responsible_function_ref=_optional_governance_text(
source.get("responsible_function_ref")
),
authoritative_source_ref=_optional_governance_text(
source.get("authoritative_source_ref")
),
authority_mode=str(
source.get("authority_mode") or "linked_reference"
), # type: ignore[arg-type]
legal_basis_refs=_governance_texts(source.get("legal_basis_refs")),
purposes=_governance_texts(source.get("purposes")),
semantic_definition=_optional_governance_text(
source.get("semantic_definition")
),
schema_owner_ref=_optional_governance_text(source.get("schema_owner_ref")),
official_keys=_governance_texts(source.get("official_keys")),
classification=str(source.get("classification") or "internal"),
privacy_profile_ref=_optional_governance_text(
source.get("privacy_profile_ref")
),
retention_policy_ref=_optional_governance_text(
source.get("retention_policy_ref")
),
hold_refs=_governance_texts(source.get("hold_refs")),
publication_state=str(source.get("publication_state") or "draft"),
transfer_agreement_ref=_optional_governance_text(
source.get("transfer_agreement_ref")
),
freshness_policy=_governance_mapping(source.get("freshness_policy")),
quality_policy=_governance_mapping(source.get("quality_policy")),
known_limits=_governance_texts(source.get("known_limits")),
correction_procedure_ref=_optional_governance_text(
source.get("correction_procedure_ref")
),
affected_refs=_governance_texts(source.get("affected_refs")),
dependency_refs=_governance_texts(source.get("dependency_refs")),
)
def to_dict(self) -> dict[str, object]:
return {
"owner_ref": self.owner_ref,
"steward_ref": self.steward_ref,
"responsible_organization_ref": self.responsible_organization_ref,
"responsible_function_ref": self.responsible_function_ref,
"authoritative_source_ref": self.authoritative_source_ref,
"authority_mode": self.authority_mode,
"legal_basis_refs": list(self.legal_basis_refs),
"purposes": list(self.purposes),
"semantic_definition": self.semantic_definition,
"schema_owner_ref": self.schema_owner_ref,
"official_keys": list(self.official_keys),
"classification": self.classification,
"privacy_profile_ref": self.privacy_profile_ref,
"retention_policy_ref": self.retention_policy_ref,
"hold_refs": list(self.hold_refs),
"publication_state": self.publication_state,
"transfer_agreement_ref": self.transfer_agreement_ref,
"freshness_policy": dict(self.freshness_policy),
"quality_policy": dict(self.quality_policy),
"known_limits": list(self.known_limits),
"correction_procedure_ref": self.correction_procedure_ref,
"affected_refs": list(self.affected_refs),
"dependency_refs": list(self.dependency_refs),
}
@dataclass(frozen=True, slots=True)
class DatasourceDescriptor:
ref: str
@@ -75,6 +211,7 @@ class DatasourceDescriptor:
capabilities: tuple[str, ...] = ("read",)
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
governance: DatasourceGovernance = field(default_factory=DatasourceGovernance)
@dataclass(frozen=True, slots=True)
@@ -93,6 +230,7 @@ class DatasourceMaterialization:
created_at: datetime | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
governance: DatasourceGovernance = field(default_factory=DatasourceGovernance)
@dataclass(frozen=True, slots=True)
@@ -115,6 +253,7 @@ class DatasourceStage:
promoted_materialization_ref: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
governance: DatasourceGovernance = field(default_factory=DatasourceGovernance)
@dataclass(frozen=True, slots=True)
@@ -151,6 +290,7 @@ class DatasourceStageInput:
provider_ref: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
governance: DatasourceGovernance | None = None
@dataclass(frozen=True, slots=True)
@@ -169,6 +309,7 @@ class DatasourcePublicationRequest:
source_timestamp: datetime | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
governance: DatasourceGovernance | None = None
@dataclass(frozen=True, slots=True)
@@ -226,6 +367,13 @@ class DatasourceCatalogueProvider(Protocol):
*,
query: str = "",
limit: int = 100,
authority_mode: str | None = None,
classification: str | None = None,
publication_state: str | None = None,
owner_ref: str | None = None,
responsible_organization_ref: str | None = None,
affected_ref: str | None = None,
dependency_ref: str | None = None,
) -> Sequence[DatasourceDescriptor]:
...
@@ -298,6 +446,17 @@ class DatasourceLifecycleProvider(Protocol):
source_name: str,
mode: DatasourceMode,
description: str | None = None,
governance: DatasourceGovernance | None = None,
) -> DatasourceDescriptor:
...
def update_datasource_governance(
self,
session: object,
principal: object,
*,
datasource_ref: str,
governance: DatasourceGovernance,
) -> DatasourceDescriptor:
...
@@ -406,6 +565,25 @@ def _capability(registry: object | None, name: str) -> object | None:
return registry.capability(name)
def _optional_governance_text(value: object) -> str | None:
if value is None:
return None
cleaned = str(value).strip()
return cleaned or None
def _governance_texts(value: object) -> tuple[str, ...]:
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
return ()
return tuple(str(item).strip() for item in value)
def _governance_mapping(value: object) -> Mapping[str, object]:
if not isinstance(value, Mapping):
return {}
return {str(key): item for key, item in value.items()}
__all__ = [
"CAPABILITY_DATASOURCE_CATALOGUE",
"CAPABILITY_DATASOURCE_LIFECYCLE",
@@ -416,6 +594,7 @@ __all__ = [
"DatasourceDescriptor",
"DatasourceError",
"DatasourceField",
"DatasourceGovernance",
"DatasourceKind",
"DatasourceLifecycleProvider",
"DatasourceMaterialization",
+9
View File
@@ -13,6 +13,8 @@ import uuid
from sqlalchemy import event as sqlalchemy_event
from sqlalchemy.orm import Session
from govoplan_core.core.institutional import GovernedContextEnvelope
_TRACE_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
_CONSUMER_ID_RE = re.compile(r"^[a-z][a-z0-9_.:-]{0,127}$")
@@ -87,6 +89,7 @@ class PlatformEvent:
subject: EventObjectRef | None = None
resource: EventObjectRef | None = None
classification: EventClassification = "internal"
institutional_context: GovernedContextEnvelope | None = None
def to_dict(self) -> dict[str, Any]:
return {
@@ -102,6 +105,11 @@ class PlatformEvent:
"subject": self.subject.to_dict() if self.subject else None,
"resource": self.resource.to_dict() if self.resource else None,
"classification": self.classification,
"institutional_context": (
self.institutional_context.to_dict()
if self.institutional_context is not None
else None
),
}
@@ -254,6 +262,7 @@ def ensure_event_trace(event: PlatformEvent) -> PlatformEvent:
subject=event.subject,
resource=event.resource,
classification=event.classification,
institutional_context=event.institutional_context,
)
@@ -17,6 +17,14 @@ IntegrationMaturity = Literal[
"migrate",
"replace",
]
SourceAuthorityMode = Literal[
"native_authoritative",
"external_authoritative",
"external_mirror",
"governed_sync",
"governance_overlay",
"linked_reference",
]
INTEGRATION_MATURITY_ORDER: tuple[IntegrationMaturity, ...] = (
"discover",
@@ -28,6 +36,14 @@ INTEGRATION_MATURITY_ORDER: tuple[IntegrationMaturity, ...] = (
"migrate",
"replace",
)
SOURCE_AUTHORITY_MODES: tuple[SourceAuthorityMode, ...] = (
"native_authoritative",
"external_authoritative",
"external_mirror",
"governed_sync",
"governance_overlay",
"linked_reference",
)
class ExternalReferenceValidationError(ValueError):
@@ -42,6 +58,7 @@ class ExternalObjectReference:
object_type: str
object_id: str
maturity: IntegrationMaturity = "link"
authority_mode: SourceAuthorityMode = "linked_reference"
connector_id: str | None = None
canonical_url: str | None = None
version: str | None = None
@@ -65,6 +82,24 @@ class ExternalObjectReference:
raise ExternalReferenceValidationError(
f"Unsupported integration maturity: {self.maturity!r}."
)
if self.authority_mode not in SOURCE_AUTHORITY_MODES:
raise ExternalReferenceValidationError(
f"Unsupported source-authority mode: {self.authority_mode!r}."
)
if (
self.authority_mode == "external_mirror"
and not self.supports("read")
):
raise ExternalReferenceValidationError(
"External-mirror references require read maturity or higher."
)
if (
self.authority_mode == "governed_sync"
and not self.supports("synchronize")
):
raise ExternalReferenceValidationError(
"Governed-sync references require synchronize maturity or higher."
)
if self.connector_id is not None:
connector_id = self.connector_id.strip()
if not connector_id:
@@ -94,6 +129,7 @@ class ExternalObjectReference:
"object_type": self.object_type,
"object_id": self.object_id,
"maturity": self.maturity,
"authority_mode": self.authority_mode,
"connector_id": self.connector_id,
"canonical_url": self.canonical_url,
"version": self.version,
@@ -133,5 +169,7 @@ __all__ = [
"ExternalReferenceValidationError",
"INTEGRATION_MATURITY_ORDER",
"IntegrationMaturity",
"SOURCE_AUTHORITY_MODES",
"SourceAuthorityMode",
"integration_maturity_rank",
]
+425 -56
View File
@@ -90,7 +90,9 @@ class _ConfigIssueCollector:
def add(self, level: ConfigIssueLevel, key: str, message: str, action: str) -> None:
if self.strict and level == "warning":
level = "error"
self.issues.append(ConfigIssue(level=level, key=key, message=message, action=action))
self.issues.append(
ConfigIssue(level=level, key=key, message=message, action=action)
)
_LOCAL_PROFILES = {"dev", "local", "local-dev", "test"}
@@ -120,9 +122,15 @@ def generate_master_key() -> str:
return Fernet.generate_key().decode("ascii")
def env_template(*, profile: str = "self-hosted", generate_secrets: bool = False) -> str:
def env_template(
*, profile: str = "self-hosted", generate_secrets: bool = False
) -> str:
clean_profile = normalize_install_profile(profile)
master_key = generate_master_key() if generate_secrets else "<generate-with-govoplan-config-env-template-generate-secrets>"
master_key = (
generate_master_key()
if generate_secrets
else "<generate-with-govoplan-config-env-template-generate-secrets>"
)
if clean_profile == "production-like":
return _production_like_env_template(master_key)
return _self_hosted_env_template(master_key)
@@ -144,14 +152,19 @@ def validate_runtime_configuration(
_validate_async_and_auth_settings(env, runtime, collector)
_validate_cors_settings(env, runtime, collector)
_validate_file_storage_settings(env, runtime, collector)
_validate_shared_state_settings(env, collector)
_validate_outbound_connector_policy(env, runtime, collector)
_validate_module_catalog_trust(env, runtime, collector)
return ConfigValidationResult(profile=runtime.name, issues=tuple(collector.issues))
def _runtime_profile(env: Mapping[str, str], *, profile: str | None) -> _RuntimeProfile:
clean_profile = normalize_install_profile(profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV"))
production = clean_profile in _PRODUCTION_PROFILES or env.get("APP_ENV", "").strip().lower() in {"prod", "production"}
clean_profile = normalize_install_profile(
profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV")
)
production = clean_profile in _PRODUCTION_PROFILES or env.get(
"APP_ENV", ""
).strip().lower() in {"prod", "production"}
production_like = production or clean_profile in _PRODUCTION_LIKE_PROFILES
return _RuntimeProfile(
name=clean_profile,
@@ -161,86 +174,222 @@ def _runtime_profile(env: Mapping[str, str], *, profile: str | None) -> _Runtime
)
def _validate_app_env(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
def _validate_app_env(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
app_env = _clean(env.get("APP_ENV"))
if not app_env and runtime.production_like:
collector.add("error", "APP_ENV", "APP_ENV is missing for a production-like install.", "Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.")
collector.add(
"error",
"APP_ENV",
"APP_ENV is missing for a production-like install.",
"Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.",
)
elif app_env.lower() in {"dev", "test", "local"} and runtime.production_like:
collector.add("error", "APP_ENV", f"APP_ENV={app_env!r} is not valid for profile {runtime.name!r}.", "Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.")
collector.add(
"error",
"APP_ENV",
f"APP_ENV={app_env!r} is not valid for profile {runtime.name!r}.",
"Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.",
)
def _validate_database_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
def _validate_database_settings(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
database_url = _clean(env.get("DATABASE_URL"))
if not database_url:
collector.add("error", "DATABASE_URL", "DATABASE_URL is missing.", "Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.")
collector.add(
"error",
"DATABASE_URL",
"DATABASE_URL is missing.",
"Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.",
)
return
backend = _database_backend(database_url)
if backend is None:
collector.add("error", "DATABASE_URL", "DATABASE_URL is not a valid SQLAlchemy URL.", "Use a value like postgresql+psycopg://user:password@host:5432/database.")
collector.add(
"error",
"DATABASE_URL",
"DATABASE_URL is not a valid SQLAlchemy URL.",
"Use a value like postgresql+psycopg://user:password@host:5432/database.",
)
return
if backend == "sqlite" and runtime.production_like:
collector.add("error", "DATABASE_URL", "SQLite is only supported for disposable local development.", "Use PostgreSQL for production-like and self-hosted installs.")
collector.add(
"error",
"DATABASE_URL",
"SQLite is only supported for disposable local development.",
"Use PostgreSQL for production-like and self-hosted installs.",
)
elif backend != "postgresql" and runtime.production:
collector.add("warning", "DATABASE_URL", f"Database backend {backend!r} is not the preferred production target.", "Use PostgreSQL unless this deployment has an explicit support decision.")
collector.add(
"warning",
"DATABASE_URL",
f"Database backend {backend!r} is not the preferred production target.",
"Use PostgreSQL unless this deployment has an explicit support decision.",
)
if backend == "postgresql" and not _clean(env.get("GOVOPLAN_DATABASE_URL_PGTOOLS")):
collector.add("warning", "GOVOPLAN_DATABASE_URL_PGTOOLS", "PostgreSQL backup/restore tools URL is missing.", "Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.")
collector.add(
"warning",
"GOVOPLAN_DATABASE_URL_PGTOOLS",
"PostgreSQL backup/restore tools URL is missing.",
"Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.",
)
def _validate_master_key(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
def _validate_master_key(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
master_key = _clean(env.get("MASTER_KEY_B64"))
if not master_key and not runtime.local:
collector.add("error", "MASTER_KEY_B64", "MASTER_KEY_B64 is required outside local dev/test.", "Generate a Fernet key with `govoplan-config env-template --generate-secrets` or `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` and store it in deployment secrets.")
collector.add(
"error",
"MASTER_KEY_B64",
"MASTER_KEY_B64 is required outside local dev/test.",
"Generate a Fernet key with `govoplan-config env-template --generate-secrets` or `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` and store it in deployment secrets.",
)
return
if not master_key:
return
error = _master_key_error(master_key)
if error:
collector.add("error", "MASTER_KEY_B64", error, "Replace MASTER_KEY_B64 with a Fernet key or base64-encoded 32-byte key.")
collector.add(
"error",
"MASTER_KEY_B64",
error,
"Replace MASTER_KEY_B64 with a Fernet key or base64-encoded 32-byte key.",
)
elif "change-me" in master_key.lower() or "generate" in master_key.lower():
collector.add("error", "MASTER_KEY_B64", "MASTER_KEY_B64 still looks like a placeholder.", "Generate a real deployment key and store it outside git.")
collector.add(
"error",
"MASTER_KEY_B64",
"MASTER_KEY_B64 still looks like a placeholder.",
"Generate a real deployment key and store it outside git.",
)
def _validate_enabled_modules(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
def _validate_enabled_modules(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
enabled_modules = _csv(env.get("ENABLED_MODULES"))
if not enabled_modules and runtime.production_like:
collector.add("error", "ENABLED_MODULES", "ENABLED_MODULES is missing.", "Set ENABLED_MODULES explicitly so startup module composition is intentional.")
collector.add(
"error",
"ENABLED_MODULES",
"ENABLED_MODULES is missing.",
"Set ENABLED_MODULES explicitly so startup module composition is intentional.",
)
elif "access" not in enabled_modules and runtime.production_like:
collector.add("error", "ENABLED_MODULES", "The access module is not enabled.", "Include `access` unless this deployment has a replacement auth/principal provider.")
collector.add(
"error",
"ENABLED_MODULES",
"The access module is not enabled.",
"Include `access` unless this deployment has a replacement auth/principal provider.",
)
elif enabled_modules and "admin" not in enabled_modules and runtime.production_like:
collector.add("warning", "ENABLED_MODULES", "The admin module is not enabled.", "Keep `admin` enabled for operator UI unless this is a deliberately headless install.")
collector.add(
"warning",
"ENABLED_MODULES",
"The admin module is not enabled.",
"Keep `admin` enabled for operator UI unless this is a deliberately headless install.",
)
def _validate_async_and_auth_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
def _validate_async_and_auth_settings(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
if _truthy(env.get("CELERY_ENABLED")) and not _clean(env.get("REDIS_URL")):
collector.add("error", "REDIS_URL", "CELERY_ENABLED=true but REDIS_URL is missing.", "Set REDIS_URL to the Redis broker/result backend used by workers.")
collector.add(
"error",
"REDIS_URL",
"CELERY_ENABLED=true but REDIS_URL is missing.",
"Set REDIS_URL to the Redis broker/result backend used by workers.",
)
if runtime.production and _truthy(env.get("DEV_BOOTSTRAP_ENABLED")):
collector.add("error", "DEV_BOOTSTRAP_ENABLED", "Development bootstrap is enabled in production.", "Set DEV_BOOTSTRAP_ENABLED=false and create first administrators through the controlled bootstrap path.")
collector.add(
"error",
"DEV_BOOTSTRAP_ENABLED",
"Development bootstrap is enabled in production.",
"Set DEV_BOOTSTRAP_ENABLED=false and create first administrators through the controlled bootstrap path.",
)
if runtime.production and not _truthy(env.get("AUTH_COOKIE_SECURE")):
collector.add("error", "AUTH_COOKIE_SECURE", "Secure auth cookies are disabled for production.", "Set AUTH_COOKIE_SECURE=true behind HTTPS.")
collector.add(
"error",
"AUTH_COOKIE_SECURE",
"Secure auth cookies are disabled for production.",
"Set AUTH_COOKIE_SECURE=true behind HTTPS.",
)
def _validate_cors_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
def _validate_cors_settings(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
cors_origins = _csv(env.get("CORS_ORIGINS"))
if runtime.production_like and not cors_origins:
collector.add("error", "CORS_ORIGINS", "CORS_ORIGINS is missing.", "Set CORS_ORIGINS to the exact WebUI origin or origins.")
collector.add(
"error",
"CORS_ORIGINS",
"CORS_ORIGINS is missing.",
"Set CORS_ORIGINS to the exact WebUI origin or origins.",
)
elif "*" in cors_origins and runtime.production_like:
collector.add("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.")
collector.add(
"error",
"CORS_ORIGINS",
"Wildcard CORS is not allowed for production-like installs.",
"Replace `*` with exact HTTPS/WebUI origins.",
)
elif runtime.production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
collector.add("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.")
collector.add(
"warning",
"CORS_ORIGINS",
"CORS_ORIGINS still contains only local development origins.",
"Set CORS_ORIGINS to the deployed WebUI origin.",
)
trusted_hosts = _csv(env.get("GOVOPLAN_TRUSTED_HOSTS"))
if runtime.production_like and not trusted_hosts:
collector.add("error", "GOVOPLAN_TRUSTED_HOSTS", "Trusted HTTP hosts are not configured.", "Set GOVOPLAN_TRUSTED_HOSTS to the exact API host names accepted by this deployment.")
collector.add(
"error",
"GOVOPLAN_TRUSTED_HOSTS",
"Trusted HTTP hosts are not configured.",
"Set GOVOPLAN_TRUSTED_HOSTS to the exact API host names accepted by this deployment.",
)
elif "*" in trusted_hosts and runtime.production_like:
collector.add("error", "GOVOPLAN_TRUSTED_HOSTS", "Wildcard trusted hosts are not allowed for production-like installs.", "Replace `*` with exact host names or narrowly scoped `*.example.org` entries.")
collector.add(
"error",
"GOVOPLAN_TRUSTED_HOSTS",
"Wildcard trusted hosts are not allowed for production-like installs.",
"Replace `*` with exact host names or narrowly scoped `*.example.org` entries.",
)
forwarded_allow_ips = _csv(env.get("FORWARDED_ALLOW_IPS"))
if runtime.production_like and "*" in forwarded_allow_ips:
collector.add("error", "FORWARDED_ALLOW_IPS", "Proxy headers must not be trusted from every address.", "Set FORWARDED_ALLOW_IPS to the reverse proxy address or network passed to Uvicorn.")
collector.add(
"error",
"FORWARDED_ALLOW_IPS",
"Proxy headers must not be trusted from every address.",
"Set FORWARDED_ALLOW_IPS to the reverse proxy address or network passed to Uvicorn.",
)
def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
def _validate_file_storage_settings(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
storage_backend = _clean(env.get("FILE_STORAGE_BACKEND")) or "local"
deployment_managed_raw = _clean(env.get("FILE_STORAGE_S3_DEPLOYMENT_MANAGED")).lower()
if deployment_managed_raw and deployment_managed_raw not in {"true", "false", "1", "0", "yes", "no", "on", "off"}:
deployment_managed_raw = _clean(
env.get("FILE_STORAGE_S3_DEPLOYMENT_MANAGED")
).lower()
endpoint_trusted_raw = _clean(env.get("FILE_STORAGE_S3_ENDPOINT_TRUSTED")).lower()
if deployment_managed_raw and deployment_managed_raw not in {
"true",
"false",
"1",
"0",
"yes",
"no",
"on",
"off",
}:
collector.add(
"error",
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
@@ -248,13 +397,30 @@ def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimePro
"Set FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false, or let the supported installer manage Garage.",
)
deployment_managed = _truthy(deployment_managed_raw)
if storage_backend == "local":
if deployment_managed:
if endpoint_trusted_raw and endpoint_trusted_raw not in {
"true",
"false",
"1",
"0",
"yes",
"no",
"on",
"off",
}:
collector.add(
"error",
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
"Managed S3 trust cannot be enabled for local file storage.",
"Set FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false.",
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
"External S3 endpoint trust must be an explicit boolean.",
"Set it only for a deployment-controlled HTTPS storage origin.",
)
endpoint_trusted = _truthy(endpoint_trusted_raw)
if storage_backend == "local":
if deployment_managed or endpoint_trusted:
collector.add(
"error",
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
"S3 endpoint trust cannot be enabled for local file storage.",
"Disable both S3 trust settings while FILE_STORAGE_BACKEND=local.",
)
_validate_local_file_storage(env, runtime, collector)
elif storage_backend == "s3":
@@ -262,16 +428,34 @@ def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimePro
env,
collector,
deployment_managed=deployment_managed,
endpoint_trusted=endpoint_trusted,
)
else:
collector.add("error", "FILE_STORAGE_BACKEND", f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.", "Use `local` or `s3`.")
collector.add(
"error",
"FILE_STORAGE_BACKEND",
f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.",
"Use `local` or `s3`.",
)
def _validate_local_file_storage(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
def _validate_local_file_storage(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
if not _clean(env.get("FILE_STORAGE_LOCAL_ROOT")) and runtime.production_like:
collector.add("error", "FILE_STORAGE_LOCAL_ROOT", "Local file storage root is missing.", "Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.")
collector.add(
"error",
"FILE_STORAGE_LOCAL_ROOT",
"Local file storage root is missing.",
"Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.",
)
elif runtime.production:
collector.add("warning", "FILE_STORAGE_BACKEND", "Production is configured for local file storage.", "Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.")
collector.add(
"warning",
"FILE_STORAGE_BACKEND",
"Production is configured for local file storage.",
"Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.",
)
def _validate_s3_file_storage(
@@ -279,10 +463,22 @@ def _validate_s3_file_storage(
collector: _ConfigIssueCollector,
*,
deployment_managed: bool,
endpoint_trusted: bool,
) -> None:
for key in ("FILE_STORAGE_S3_ENDPOINT_URL", "FILE_STORAGE_S3_REGION", "FILE_STORAGE_S3_ACCESS_KEY_ID", "FILE_STORAGE_S3_SECRET_ACCESS_KEY", "FILE_STORAGE_S3_BUCKET"):
for key in (
"FILE_STORAGE_S3_ENDPOINT_URL",
"FILE_STORAGE_S3_REGION",
"FILE_STORAGE_S3_ACCESS_KEY_ID",
"FILE_STORAGE_S3_SECRET_ACCESS_KEY",
"FILE_STORAGE_S3_BUCKET",
):
if not _clean(env.get(key)):
collector.add("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
collector.add(
"error",
key,
f"{key} is required when FILE_STORAGE_BACKEND=s3.",
"Configure all FILE_STORAGE_S3_* settings through deployment secrets.",
)
if (
deployment_managed
and _clean(env.get("FILE_STORAGE_S3_ENDPOINT_URL")) != "http://garage:3900"
@@ -293,6 +489,126 @@ def _validate_s3_file_storage(
"Installer-managed S3 trust is restricted to http://garage:3900.",
"Use the exact managed Garage endpoint or disable FILE_STORAGE_S3_DEPLOYMENT_MANAGED.",
)
if deployment_managed and endpoint_trusted:
collector.add(
"error",
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
"Managed Garage trust and external endpoint trust are mutually exclusive.",
"Use installer-managed Garage trust or one explicit external endpoint.",
)
if not deployment_managed and not endpoint_trusted:
collector.add(
"error",
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
"External S3 storage requires an explicit deployment trust decision.",
"Set FILE_STORAGE_S3_ENDPOINT_TRUSTED=true only for a deployment-controlled HTTPS origin.",
)
endpoint = _clean(env.get("FILE_STORAGE_S3_ENDPOINT_URL"))
if endpoint_trusted and not endpoint.lower().startswith("https://"):
collector.add(
"error",
"FILE_STORAGE_S3_ENDPOINT_URL",
"Deployment-trusted external S3 storage must use HTTPS.",
"Use an HTTPS storage origin with certificate verification.",
)
def _validate_shared_state_settings(
env: Mapping[str, str],
collector: _ConfigIssueCollector,
) -> None:
state_profile = (_clean(env.get("GOVOPLAN_STATE_PROFILE")) or "local").lower()
if state_profile not in {"local", "host-shared", "shared"}:
collector.add(
"error",
"GOVOPLAN_STATE_PROFILE",
f"Unsupported state profile {state_profile!r}.",
"Use `local` for one process per role, `host-shared` for one Compose host, or `shared` for a multi-host stateless tier.",
)
return
try:
api_replicas = int(_clean(env.get("GOVOPLAN_EXPECTED_API_REPLICAS")) or "1")
worker_replicas = int(
_clean(env.get("GOVOPLAN_EXPECTED_WORKER_REPLICAS")) or "0"
)
except ValueError:
collector.add(
"error",
"GOVOPLAN_EXPECTED_API_REPLICAS",
"Expected replica counts must be integers.",
"Set GOVOPLAN_EXPECTED_API_REPLICAS and GOVOPLAN_EXPECTED_WORKER_REPLICAS to non-negative integers.",
)
return
if api_replicas < 1 or worker_replicas < 0:
collector.add(
"error",
"GOVOPLAN_EXPECTED_API_REPLICAS",
"Expected replica counts are outside their supported range.",
"Configure at least one API replica and zero or more worker replicas.",
)
if state_profile == "local":
if api_replicas > 1 or worker_replicas > 1:
collector.add(
"error",
"GOVOPLAN_STATE_PROFILE",
"A local-state profile cannot safely run replicated API or worker nodes.",
"Use `host-shared` with one shared host volume, or `shared` with PostgreSQL, Redis, and S3-compatible object storage.",
)
return
installation_id = _clean(env.get("GOVOPLAN_INSTALLATION_ID"))
if not installation_id or (
state_profile == "shared" and installation_id == "govoplan-local"
):
collector.add(
"error",
"GOVOPLAN_INSTALLATION_ID",
"Shared-state deployments require a stable installation identifier.",
"Set one immutable deployment-wide GOVOPLAN_INSTALLATION_ID on every node.",
)
if _database_backend(_clean(env.get("DATABASE_URL"))) != "postgresql":
collector.add(
"error",
"DATABASE_URL",
"Shared-state deployments require PostgreSQL.",
"Point every API, scheduler, and worker node at the same logical PostgreSQL service.",
)
if not _clean(env.get("REDIS_URL")):
collector.add(
"error",
"REDIS_URL",
"Shared-state deployments require a common Redis service.",
"Configure the same Redis endpoint for all API and worker nodes.",
)
if (
state_profile == "shared"
and (_clean(env.get("FILE_STORAGE_BACKEND")) or "local").lower() != "s3"
):
collector.add(
"error",
"FILE_STORAGE_BACKEND",
"Shared-state deployments cannot use node-local object storage.",
"Set FILE_STORAGE_BACKEND=s3 and configure one shared S3-compatible bucket.",
)
try:
heartbeat = int(_clean(env.get("GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS")) or "15")
stale_after = int(
_clean(env.get("GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS")) or "60"
)
except ValueError:
collector.add(
"error",
"GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS",
"Runtime heartbeat and stale intervals must be integers.",
"Use a heartbeat interval shorter than one third of the stale interval.",
)
else:
if heartbeat < 2 or stale_after < max(10, heartbeat * 3):
collector.add(
"error",
"GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS",
"Runtime stale detection leaves insufficient room for missed heartbeats.",
"Set stale-after to at least three heartbeat intervals and at least ten seconds.",
)
def _validate_outbound_connector_policy(
@@ -300,8 +616,19 @@ def _validate_outbound_connector_policy(
runtime: _RuntimeProfile,
collector: _ConfigIssueCollector,
) -> None:
private_networks = _clean(env.get("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS")).lower()
if runtime.production_like and private_networks not in {"true", "false", "1", "0", "yes", "no", "on", "off"}:
private_networks = _clean(
env.get("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS")
).lower()
if runtime.production_like and private_networks not in {
"true",
"false",
"1",
"0",
"yes",
"no",
"on",
"off",
}:
collector.add(
"error",
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
@@ -321,13 +648,21 @@ def _validate_outbound_connector_policy(
except ValueError:
parsed = 0
if parsed <= 0:
collector.add("error", key, f"{key} must be a positive byte count.", "Use a positive integer byte limit.")
collector.add(
"error",
key,
f"{key} must be a positive byte count.",
"Use a positive integer byte limit.",
)
secret_env_names = [
item.strip()
for item in env.get("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST", "").split(",")
if item.strip()
]
if any(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", item) is None for item in secret_env_names):
if any(
re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", item) is None
for item in secret_env_names
):
collector.add(
"error",
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
@@ -348,14 +683,28 @@ def _validate_outbound_connector_policy(
)
def _validate_module_catalog_trust(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG"))
def _validate_module_catalog_trust(
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(
env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG")
)
if not runtime.production or not catalog_source:
return
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE")):
collector.add("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "A module catalog source is configured without a trusted keyring file.", "Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.")
collector.add(
"error",
"GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE",
"A module catalog source is configured without a trusted keyring file.",
"Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.",
)
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL")):
collector.add("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL", "A module catalog source is configured without an approved release channel.", "Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.")
collector.add(
"error",
"GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL",
"A module catalog source is configured without an approved release channel.",
"Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.",
)
def _self_hosted_env_template(master_key: str) -> str:
@@ -364,6 +713,13 @@ def _self_hosted_env_template(master_key: str) -> str:
APP_ENV=production
GOVOPLAN_INSTALL_PROFILE=self-hosted
GOVOPLAN_INSTALLATION_ID=govoplan-production
GOVOPLAN_STATE_PROFILE=local
GOVOPLAN_RUNTIME_ROLE=api
GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS=15
GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS=60
GOVOPLAN_EXPECTED_API_REPLICAS=1
GOVOPLAN_EXPECTED_WORKER_REPLICAS=1
MASTER_KEY_B64={master_key}
DATABASE_URL=postgresql+psycopg://govoplan:change-me@127.0.0.1:5432/govoplan
@@ -409,6 +765,7 @@ FILE_STORAGE_BACKEND=local
FILE_STORAGE_LOCAL_ROOT=/var/lib/govoplan/files
FILE_STORAGE_LOCAL_FALLBACK_ROOTS=
FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false
FILE_STORAGE_S3_ENDPOINT_TRUSTED=false
FILE_ARCHIVE_MAX_ENTRIES=10000
FILE_ARCHIVE_MAX_EXPANDED_BYTES=2147483648
FILE_ARCHIVE_MAX_EXPANSION_RATIO=100
@@ -430,6 +787,13 @@ def _production_like_env_template(master_key: str) -> str:
APP_ENV=staging
GOVOPLAN_INSTALL_PROFILE=production-like
GOVOPLAN_INSTALLATION_ID=govoplan-production-like
GOVOPLAN_STATE_PROFILE=local
GOVOPLAN_RUNTIME_ROLE=api
GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS=15
GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS=60
GOVOPLAN_EXPECTED_API_REPLICAS=1
GOVOPLAN_EXPECTED_WORKER_REPLICAS=1
MASTER_KEY_B64={master_key}
GOVOPLAN_PRODUCTION_LIKE_POSTGRES_DB=govoplan
@@ -474,6 +838,7 @@ AUTH_COOKIE_SECURE=false
FILE_STORAGE_BACKEND=local
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false
FILE_STORAGE_S3_ENDPOINT_TRUSTED=false
FILE_ARCHIVE_MAX_ENTRIES=10000
FILE_ARCHIVE_MAX_EXPANDED_BYTES=2147483648
FILE_ARCHIVE_MAX_EXPANSION_RATIO=100
@@ -488,7 +853,11 @@ def _clean(value: str | None) -> str:
def _csv(value: str | None) -> tuple[str, ...]:
return tuple(dict.fromkeys(item.strip() for item in str(value or "").split(",") if item.strip()))
return tuple(
dict.fromkeys(
item.strip() for item in str(value or "").split(",") if item.strip()
)
)
def _truthy(value: str | None) -> bool:
File diff suppressed because it is too large Load Diff
@@ -335,6 +335,18 @@ def module_install_preflight(
issues.append(ModuleInstallerIssue("warning", "empty_plan", "No planned package changes are present."))
if not maintenance_mode:
issues.append(ModuleInstallerIssue("blocker", "maintenance_required", "Package changes require maintenance mode."))
if os.getenv("GOVOPLAN_STATE_PROFILE", "local").strip().lower() == "shared":
issues.append(
ModuleInstallerIssue(
"blocker",
"immutable_cluster_release_required",
(
"Shared-state deployments cannot mutate packages on one runtime node. "
"Build and roll out one immutable release image across every API, worker, "
"and scheduler node."
),
)
)
activation_candidates = desired_modules_after_package_plan(desired_sequence, plan)
issues.extend(module_manifest_compatibility_issues(available, module_ids=activation_candidates))
@@ -3,6 +3,7 @@ from __future__ import annotations
import base64
import binascii
from collections import defaultdict
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
@@ -16,6 +17,11 @@ from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
from govoplan_core.core.provider_governance import (
external_provider_from_mapping,
module_architecture_from_mapping,
module_architecture_issues,
)
from govoplan_core.security.http_fetch import fetch_http_text, is_http_url
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
@@ -613,6 +619,72 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
"notes": _optional_str(value, "notes"),
"tags": _string_list(value.get("tags")),
}
raw_architecture = value.get("architecture")
if raw_architecture is not None:
if not isinstance(raw_architecture, Mapping):
raise ValueError(
f"Module package catalog architecture for {module_id!r} must be an object."
)
architecture = module_architecture_from_mapping(raw_architecture)
issues = module_architecture_issues(
architecture,
has_migrations=bool(item["migration_tasks"])
or bool(item["migration_notes"]),
)
if issues:
raise ValueError(
f"Module package catalog architecture for {module_id!r} is invalid: "
+ "; ".join(issues)
)
item["architecture"] = architecture.to_dict()
raw_providers = value.get("external_providers")
if raw_providers is not None:
if not isinstance(raw_providers, list):
raise ValueError(
f"Module package catalog external_providers for {module_id!r} must be a list."
)
providers = []
seen_provider_ids: set[str] = set()
for raw_provider in raw_providers:
if not isinstance(raw_provider, Mapping):
raise ValueError(
f"Module package catalog external provider entries for {module_id!r} must be objects."
)
provider = external_provider_from_mapping(raw_provider)
if provider.module_id != module_id:
raise ValueError(
f"Module package catalog provider {provider.id!r} belongs to "
f"{provider.module_id!r}, not {module_id!r}."
)
if provider.id in seen_provider_ids:
raise ValueError(
f"Module package catalog has duplicate provider {provider.id!r}."
)
seen_provider_ids.add(provider.id)
providers.append(provider)
if providers and "architecture" not in item:
raise ValueError(
f"Module package catalog {module_id!r} declares external providers without architecture metadata."
)
if providers:
architecture_payload = item["architecture"]
if not isinstance(architecture_payload, Mapping):
raise ValueError(
f"Module package catalog {module_id!r} has invalid architecture metadata."
)
architecture_modes = set(
_string_list(architecture_payload.get("supported_authority_modes"))
)
provider_modes = {
mode for provider in providers for mode in provider.authority_modes
}
missing_modes = provider_modes - architecture_modes
if missing_modes:
raise ValueError(
f"Module package catalog {module_id!r} provider modes are missing from architecture metadata: "
+ ", ".join(sorted(missing_modes))
)
item["external_providers"] = [provider.to_dict() for provider in providers]
if not version_range_is_valid(
version_min=item["current_version_min"] if isinstance(item["current_version_min"], str) else None,
version_max_exclusive=item["current_version_max_exclusive"] if isinstance(item["current_version_max_exclusive"], str) else None,
+11
View File
@@ -5,6 +5,11 @@ from dataclasses import dataclass, field
from typing import Any, Literal, Protocol, TYPE_CHECKING
from govoplan_core.core.ownership import OwnershipProviderRegistration
from govoplan_core.core.provider_governance import (
ExternalProviderDeclaration,
ExternalProviderStateProviderRegistration,
ModuleArchitectureDeclaration,
)
from govoplan_core.core.views import ViewSurface
if TYPE_CHECKING:
@@ -438,6 +443,12 @@ class ModuleManifest:
"OperationalCheckProviderRegistration",
...,
] = ()
architecture: ModuleArchitectureDeclaration | None = None
external_providers: tuple[ExternalProviderDeclaration, ...] = ()
external_provider_state_providers: tuple[
ExternalProviderStateProviderRegistration,
...,
] = ()
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
on_activate: LifecycleHook | None = None
on_deactivate: LifecycleHook | None = None
+597
View File
@@ -0,0 +1,597 @@
from __future__ import annotations
from dataclasses import dataclass, field
from heapq import nsmallest
import os
from pathlib import Path
import tempfile
from typing import Any, Iterable, Protocol
from urllib.parse import urlsplit
from govoplan_core.security.outbound_http import (
OutboundHttpError,
response_limit,
validate_unpinned_sdk_http_url,
)
class StorageBackendError(RuntimeError):
"""Base error for the deployment-owned object-storage boundary."""
class StorageObjectMissing(StorageBackendError):
"""Raised when a referenced object no longer exists."""
@dataclass(frozen=True, slots=True)
class StorageObjectInfo:
key: str
size_bytes: int
@dataclass(frozen=True, slots=True)
class StorageObjectPage:
objects: tuple[StorageObjectInfo, ...]
next_cursor: str | None = None
class StorageBackend(Protocol):
"""Shared byte-object storage used by modules without cross-module imports."""
name: str
def put_bytes(
self,
key: str,
data: bytes,
*,
content_type: str | None = None,
) -> None: ...
def get_bytes(self, key: str) -> bytes: ...
def iter_bytes(
self,
key: str,
*,
chunk_size: int = 1024 * 1024,
) -> Iterable[bytes]: ...
def delete(self, key: str) -> None: ...
def exists(self, key: str) -> bool: ...
def stat(self, key: str) -> StorageObjectInfo: ...
def list_objects(
self,
*,
prefix: str,
after: str | None = None,
limit: int = 500,
) -> StorageObjectPage: ...
@dataclass(slots=True)
class LocalFilesystemStorageBackend:
root: Path
fallback_roots: tuple[Path, ...] = field(default_factory=tuple)
name: str = "local"
def __post_init__(self) -> None:
self.root = self.root.expanduser().resolve()
self.fallback_roots = tuple(
root.expanduser().resolve() for root in self.fallback_roots if root
)
self.root.mkdir(parents=True, exist_ok=True)
def _path_for_root(self, root: Path, key: str) -> Path:
normalized = normalize_storage_key(key)
path = (root / normalized).resolve()
if not path.is_relative_to(root):
raise StorageBackendError("Storage key escapes local storage root")
return path
def _path(self, key: str) -> Path:
return self._path_for_root(self.root, key)
def _readable_path(self, key: str) -> Path:
primary = self._path(key)
if primary.exists() and primary.is_file():
return primary
for root in self.fallback_roots:
candidate = self._path_for_root(root, key)
if candidate.exists() and candidate.is_file():
return candidate
raise StorageObjectMissing("Stored object does not exist")
def put_bytes(
self,
key: str,
data: bytes,
*,
content_type: str | None = None,
) -> None:
del content_type
path = self._path(key)
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(
prefix=f".{path.name}.",
suffix=".tmp",
dir=path.parent,
)
temporary = Path(temporary_name)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "wb") as stream:
descriptor = -1
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
temporary.replace(path)
finally:
if descriptor >= 0:
os.close(descriptor)
temporary.unlink(missing_ok=True)
def get_bytes(self, key: str) -> bytes:
return self._readable_path(key).read_bytes()
def iter_bytes(
self,
key: str,
*,
chunk_size: int = 1024 * 1024,
) -> Iterable[bytes]:
path = self._readable_path(key)
with path.open("rb") as handle:
while True:
chunk = handle.read(chunk_size)
if not chunk:
break
yield chunk
def delete(self, key: str) -> None:
path = self._path(key)
if path.exists() and path.is_file():
path.unlink()
def exists(self, key: str) -> bool:
try:
self._readable_path(key)
except StorageObjectMissing:
return False
return True
def stat(self, key: str) -> StorageObjectInfo:
path = self._readable_path(key)
return StorageObjectInfo(
key=normalize_storage_key(key),
size_bytes=path.stat().st_size,
)
def list_objects(
self,
*,
prefix: str,
after: str | None = None,
limit: int = 500,
) -> StorageObjectPage:
normalized_prefix = normalize_storage_prefix(prefix)
normalized_after = normalize_storage_key(after) if after else None
normalized_limit = max(1, min(int(limit), 5000))
def matching_objects() -> Iterable[StorageObjectInfo]:
for path in _iter_local_files(self.root):
key = path.relative_to(self.root).as_posix()
if not key.startswith(normalized_prefix) or (
normalized_after is not None and key <= normalized_after
):
continue
yield StorageObjectInfo(
key=key,
size_bytes=path.stat().st_size,
)
candidates = nsmallest(
normalized_limit + 1,
matching_objects(),
key=lambda item: item.key,
)
has_more = len(candidates) > normalized_limit
page = tuple(candidates[:normalized_limit])
return StorageObjectPage(
objects=page,
next_cursor=page[-1].key if has_more and page else None,
)
@dataclass(slots=True)
class S3StorageBackend:
bucket: str
endpoint_url: str
region_name: str
access_key_id: str
secret_access_key: str
deployment_managed: bool = False
endpoint_trusted: bool = False
name: str = "s3"
_client: Any = field(default=None, init=False, repr=False)
@property
def client(self):
if self._client is not None:
return self._client
if self.deployment_managed:
endpoint_url = _deployment_managed_garage_endpoint(self.endpoint_url)
elif self.endpoint_trusted:
endpoint_url = _trusted_deployment_endpoint(self.endpoint_url)
else:
try:
endpoint_url = validate_unpinned_sdk_http_url(
self.endpoint_url,
label="Object storage S3 endpoint",
)
except OutboundHttpError as exc:
raise StorageBackendError(str(exc)) from exc
try:
import boto3
from botocore.config import Config
except ModuleNotFoundError as exc:
raise StorageBackendError(
"boto3 is required for the S3 storage backend"
) from exc
options: dict[str, object] = {
"endpoint_url": endpoint_url,
"region_name": self.region_name,
"aws_access_key_id": self.access_key_id,
"aws_secret_access_key": self.secret_access_key,
}
if self.deployment_managed:
options["config"] = Config(s3={"addressing_style": "path"})
self._client = boto3.client("s3", **options)
return self._client
def put_bytes(
self,
key: str,
data: bytes,
*,
content_type: str | None = None,
) -> None:
normalized = normalize_storage_key(key)
max_bytes = response_limit("file")
if len(data) > max_bytes:
raise StorageBackendError(
f"Stored object exceeds the deployment limit of {max_bytes} bytes"
)
kwargs: dict[str, object] = {
"Bucket": self.bucket,
"Key": normalized,
"Body": data,
}
if content_type:
kwargs["ContentType"] = content_type
try:
self.client.put_object(**kwargs)
except Exception as exc: # pragma: no cover - depends on S3 backend
raise StorageBackendError(str(exc)) from exc
def get_bytes(self, key: str) -> bytes:
normalized = normalize_storage_key(key)
try:
obj = self.client.get_object(
Bucket=self.bucket,
Key=normalized,
)
max_bytes = response_limit("file")
body = obj["Body"]
try:
_reject_declared_object_size(obj, max_bytes=max_bytes)
data = body.read(max_bytes + 1)
if len(data) > max_bytes:
raise StorageBackendError(
"Stored object exceeds the deployment limit of "
f"{max_bytes} bytes"
)
return data
finally:
if hasattr(body, "close"):
body.close()
except StorageBackendError:
raise
except Exception as exc: # pragma: no cover - depends on S3 backend
if _s3_missing_error(exc):
raise StorageObjectMissing("Stored object does not exist") from exc
raise StorageBackendError(str(exc)) from exc
def iter_bytes(
self,
key: str,
*,
chunk_size: int = 1024 * 1024,
) -> Iterable[bytes]:
normalized = normalize_storage_key(key)
try:
obj = self.client.get_object(
Bucket=self.bucket,
Key=normalized,
)
max_bytes = response_limit("file")
body = obj["Body"]
try:
_reject_declared_object_size(obj, max_bytes=max_bytes)
total = 0
while True:
chunk = body.read(chunk_size)
if not chunk:
break
total += len(chunk)
if total > max_bytes:
raise StorageBackendError(
"Stored object exceeds the deployment limit of "
f"{max_bytes} bytes"
)
yield chunk
finally:
if hasattr(body, "close"):
body.close()
except StorageBackendError:
raise
except Exception as exc: # pragma: no cover - depends on S3 backend
if _s3_missing_error(exc):
raise StorageObjectMissing("Stored object does not exist") from exc
raise StorageBackendError(str(exc)) from exc
def delete(self, key: str) -> None:
try:
self.client.delete_object(
Bucket=self.bucket,
Key=normalize_storage_key(key),
)
except Exception as exc: # pragma: no cover - depends on S3 backend
raise StorageBackendError(str(exc)) from exc
def exists(self, key: str) -> bool:
try:
self.client.head_object(
Bucket=self.bucket,
Key=normalize_storage_key(key),
)
return True
except Exception as exc:
if _s3_missing_error(exc):
return False
raise StorageBackendError(str(exc)) from exc
def stat(self, key: str) -> StorageObjectInfo:
normalized = normalize_storage_key(key)
try:
response = self.client.head_object(
Bucket=self.bucket,
Key=normalized,
)
except Exception as exc: # pragma: no cover - depends on S3 backend
if _s3_missing_error(exc):
raise StorageObjectMissing("Stored object does not exist") from exc
raise StorageBackendError(str(exc)) from exc
try:
size = int(response.get("ContentLength"))
except (AttributeError, TypeError, ValueError) as exc:
raise StorageBackendError(
"S3 object metadata did not include a valid size"
) from exc
return StorageObjectInfo(key=normalized, size_bytes=size)
def list_objects(
self,
*,
prefix: str,
after: str | None = None,
limit: int = 500,
) -> StorageObjectPage:
normalized_prefix = normalize_storage_prefix(prefix)
normalized_limit = max(1, min(int(limit), 1000))
kwargs: dict[str, object] = {
"Bucket": self.bucket,
"Prefix": normalized_prefix,
"MaxKeys": normalized_limit,
}
if after:
kwargs["StartAfter"] = normalize_storage_key(after)
try:
response = self.client.list_objects_v2(**kwargs)
except Exception as exc: # pragma: no cover - depends on S3 backend
raise StorageBackendError(str(exc)) from exc
objects = tuple(
StorageObjectInfo(
key=str(item["Key"]),
size_bytes=int(item.get("Size") or 0),
)
for item in response.get("Contents", ())
if isinstance(item, dict) and item.get("Key")
)
has_more = bool(response.get("IsTruncated"))
return StorageObjectPage(
objects=objects,
next_cursor=objects[-1].key if has_more and objects else None,
)
def configured_storage_backend(settings: object) -> StorageBackend:
"""Build the deployment-wide object store from Core settings.
Modules own their metadata and key namespaces. The deployment owns the
storage endpoint and credentials, so modules do not need to depend on the
Files package merely to persist opaque generated bytes.
"""
configured = (
str(getattr(settings, "file_storage_backend", "local") or "local")
.strip()
.lower()
)
if configured in {"local", "filesystem", "fs"}:
raw_fallbacks = str(
getattr(settings, "file_storage_local_fallback_roots", "") or ""
)
return LocalFilesystemStorageBackend(
Path(
str(
getattr(
settings,
"file_storage_local_root",
"runtime/files",
)
)
),
fallback_roots=tuple(
Path(item.strip()) for item in raw_fallbacks.split(",") if item.strip()
),
)
if configured in {"s3", "garage"}:
return S3StorageBackend(
bucket=str(
getattr(settings, "file_storage_s3_bucket", None)
or getattr(settings, "s3_bucket", "files")
),
endpoint_url=str(
getattr(settings, "file_storage_s3_endpoint_url", None)
or getattr(settings, "s3_endpoint_url", "")
),
region_name=str(
getattr(settings, "file_storage_s3_region", None)
or getattr(settings, "s3_region", "")
),
access_key_id=str(
getattr(settings, "file_storage_s3_access_key_id", None)
or getattr(settings, "s3_access_key_id", "")
),
secret_access_key=str(
getattr(
settings,
"file_storage_s3_secret_access_key",
None,
)
or getattr(settings, "s3_secret_access_key", "")
),
deployment_managed=bool(
getattr(
settings,
"file_storage_s3_deployment_managed",
False,
)
),
endpoint_trusted=bool(
getattr(
settings,
"file_storage_s3_endpoint_trusted",
False,
)
),
)
raise StorageBackendError(f"Unsupported object storage backend: {configured}")
def normalize_storage_key(value: str) -> str:
candidate = str(value or "").strip().replace("\\", "/")
parts = candidate.split("/")
if (
not candidate
or candidate.startswith("/")
or any(part in {"", ".", ".."} for part in parts)
or any(ord(character) < 32 for character in candidate)
):
raise StorageBackendError("Storage key is not a safe relative key")
return "/".join(parts)
def normalize_storage_prefix(value: str) -> str:
candidate = str(value or "").strip().replace("\\", "/")
if not candidate:
return ""
trailing_slash = candidate.endswith("/")
normalized = normalize_storage_key(candidate.rstrip("/"))
return normalized + ("/" if trailing_slash else "")
def _reject_declared_object_size(obj: object, *, max_bytes: int) -> None:
if not isinstance(obj, dict):
return
try:
declared_size = int(obj.get("ContentLength"))
except (TypeError, ValueError):
return
if declared_size > max_bytes:
raise StorageBackendError(
f"Stored object exceeds the deployment limit of {max_bytes} bytes"
)
def _iter_local_files(root: Path):
for entry in sorted(root.iterdir(), key=lambda item: item.name):
if entry.is_symlink():
continue
if entry.is_dir():
yield from _iter_local_files(entry)
elif entry.is_file():
yield entry
def _s3_missing_error(exc: Exception) -> bool:
response = getattr(exc, "response", None)
if not isinstance(response, dict):
return False
error = response.get("Error")
metadata = response.get("ResponseMetadata")
code = str(error.get("Code") if isinstance(error, dict) else "")
status_code = metadata.get("HTTPStatusCode") if isinstance(metadata, dict) else None
return code in {"404", "NoSuchKey", "NotFound"} or status_code == 404
def _deployment_managed_garage_endpoint(value: str) -> str:
endpoint = str(value or "").strip()
if endpoint != "http://garage:3900":
raise StorageBackendError(
"Deployment-managed S3 trust is restricted to http://garage:3900"
)
return endpoint
def _trusted_deployment_endpoint(value: str) -> str:
endpoint = str(value or "").strip()
parsed = urlsplit(endpoint)
try:
parsed.port
except ValueError as exc:
raise StorageBackendError(
"Deployment-trusted S3 endpoint has an invalid port"
) from exc
if (
parsed.scheme.lower() != "https"
or not parsed.hostname
or parsed.username
or parsed.password
or parsed.query
or parsed.fragment
or parsed.path not in {"", "/"}
):
raise StorageBackendError(
"Deployment-trusted S3 endpoint must be an HTTPS origin without "
"credentials, query, fragment, or path"
)
return endpoint.rstrip("/")
__all__ = [
"LocalFilesystemStorageBackend",
"S3StorageBackend",
"StorageBackend",
"StorageBackendError",
"StorageObjectInfo",
"StorageObjectMissing",
"StorageObjectPage",
"configured_storage_backend",
"normalize_storage_key",
"normalize_storage_prefix",
]
File diff suppressed because it is too large Load Diff
+669
View File
@@ -0,0 +1,669 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import StrEnum
import hashlib
import json
from typing import Any
from uuid import uuid4
from sqlalchemy import (
BigInteger,
DateTime,
ForeignKey,
Index,
Integer,
JSON,
String,
Text,
UniqueConstraint,
select,
)
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Mapped, Session, mapped_column
from govoplan_core.core.runtime_coordination import LeaseClaim, assert_lease_fence
from govoplan_core.db.base import Base, TimestampMixin, utcnow
from govoplan_core.security.redaction import contains_plain_secret
class RecoveryMode(StrEnum):
ATOMIC = "atomic"
COMPENSATION = "compensation"
SNAPSHOT_RESTORE = "snapshot_restore"
FORWARD_RECOVERY = "forward_recovery"
IRREVERSIBLE = "irreversible"
class RecoveryStatus(StrEnum):
PLANNED = "planned"
PREPARED = "prepared"
RUNNING = "running"
SUCCEEDED = "succeeded"
FAILED = "failed"
OUTCOME_UNKNOWN = "outcome_unknown"
RECOVERY_REQUIRED = "recovery_required"
RECOVERING = "recovering"
RECOVERED = "recovered"
MANUAL_INTERVENTION = "manual_intervention"
TERMINAL_RECOVERY_STATUSES = frozenset(
{
RecoveryStatus.SUCCEEDED.value,
RecoveryStatus.FAILED.value,
RecoveryStatus.RECOVERED.value,
RecoveryStatus.MANUAL_INTERVENTION.value,
}
)
_TRANSITIONS: dict[str, frozenset[str]] = {
RecoveryStatus.PLANNED.value: frozenset(
{RecoveryStatus.PREPARED.value, RecoveryStatus.FAILED.value}
),
RecoveryStatus.PREPARED.value: frozenset(
{RecoveryStatus.RUNNING.value, RecoveryStatus.FAILED.value}
),
RecoveryStatus.RUNNING.value: frozenset(
{
RecoveryStatus.SUCCEEDED.value,
RecoveryStatus.FAILED.value,
RecoveryStatus.OUTCOME_UNKNOWN.value,
RecoveryStatus.RECOVERY_REQUIRED.value,
}
),
RecoveryStatus.OUTCOME_UNKNOWN.value: frozenset(
{
RecoveryStatus.SUCCEEDED.value,
RecoveryStatus.RECOVERY_REQUIRED.value,
RecoveryStatus.MANUAL_INTERVENTION.value,
}
),
RecoveryStatus.RECOVERY_REQUIRED.value: frozenset(
{
RecoveryStatus.RECOVERING.value,
RecoveryStatus.MANUAL_INTERVENTION.value,
}
),
RecoveryStatus.RECOVERING.value: frozenset(
{
RecoveryStatus.RECOVERED.value,
RecoveryStatus.MANUAL_INTERVENTION.value,
}
),
}
class RecoveryGuaranteeError(ValueError):
pass
class RecoveryIdempotencyConflict(RecoveryGuaranteeError):
pass
class RecoveryOperation(Base, TimestampMixin):
__tablename__ = "core_recovery_operations"
__table_args__ = (
UniqueConstraint(
"installation_id",
"module_id",
"idempotency_key",
name="uq_core_recovery_operation_idempotency",
),
Index(
"ix_core_recovery_operations_status_updated",
"installation_id",
"status",
"updated_at",
),
Index(
"ix_core_recovery_operations_resource",
"module_id",
"resource_type",
"resource_id",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid4()),
)
installation_id: Mapped[str] = mapped_column(
String(100), nullable=False, index=True
)
module_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
operation_type: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
resource_type: Mapped[str | None] = mapped_column(String(100), index=True)
resource_id: Mapped[str | None] = mapped_column(String(255), index=True)
mode: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
status: Mapped[str] = mapped_column(
String(40),
default=RecoveryStatus.PLANNED.value,
nullable=False,
index=True,
)
idempotency_key: Mapped[str] = mapped_column(String(200), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
plan: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
backup_reference: Mapped[str | None] = mapped_column(String(1000))
approval_reference: Mapped[str | None] = mapped_column(String(1000))
lease_resource_key: Mapped[str | None] = mapped_column(String(255))
holder_node_id: Mapped[str | None] = mapped_column(String(200))
holder_incarnation: Mapped[str | None] = mapped_column(String(36))
fencing_token: Mapped[int | None] = mapped_column(
BigInteger().with_variant(Integer, "sqlite")
)
checkpoint_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
evidence_head_sha256: Mapped[str | None] = mapped_column(String(64))
failure_summary: Mapped[str | None] = mapped_column(Text)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
recovery_started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True)
)
recovered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
metadata_: Mapped[dict[str, Any]] = mapped_column(
"metadata", JSON, default=dict, nullable=False
)
class RecoveryCheckpoint(Base):
__tablename__ = "core_recovery_checkpoints"
__table_args__ = (
UniqueConstraint(
"operation_id",
"sequence",
name="uq_core_recovery_checkpoint_sequence",
),
Index(
"ix_core_recovery_checkpoints_operation_created",
"operation_id",
"created_at",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid4()),
)
operation_id: Mapped[str] = mapped_column(
ForeignKey("core_recovery_operations.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
kind: Mapped[str] = mapped_column(String(80), nullable=False)
summary: Mapped[str] = mapped_column(Text, nullable=False)
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
previous_sha256: Mapped[str | None] = mapped_column(String(64))
checkpoint_sha256: Mapped[str] = mapped_column(
String(64), nullable=False, index=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, nullable=False
)
@dataclass(frozen=True, slots=True)
class RecoveryPlan:
mode: RecoveryMode
preconditions: tuple[str, ...] = ()
compensation_steps: tuple[str, ...] = ()
forward_recovery_steps: tuple[str, ...] = ()
verification_steps: tuple[str, ...] = ()
backup_reference: str | None = None
approval_reference: str | None = None
def validate(self) -> None:
if not self.verification_steps:
raise RecoveryGuaranteeError(
"Recovery plans require at least one verification step"
)
if self.mode == RecoveryMode.COMPENSATION and not self.compensation_steps:
raise RecoveryGuaranteeError(
"Compensation recovery requires explicit compensation steps"
)
if self.mode == RecoveryMode.SNAPSHOT_RESTORE and not self.backup_reference:
raise RecoveryGuaranteeError(
"Snapshot restore requires a verified backup reference"
)
if (
self.mode == RecoveryMode.FORWARD_RECOVERY
and not self.forward_recovery_steps
):
raise RecoveryGuaranteeError(
"Forward recovery requires explicit forward-recovery steps"
)
if self.mode == RecoveryMode.IRREVERSIBLE and not self.approval_reference:
raise RecoveryGuaranteeError(
"Irreversible operations require an approval reference"
)
def as_dict(self) -> dict[str, Any]:
return {
"mode": self.mode.value,
"preconditions": list(self.preconditions),
"compensation_steps": list(self.compensation_steps),
"forward_recovery_steps": list(self.forward_recovery_steps),
"verification_steps": list(self.verification_steps),
"backup_reference": self.backup_reference,
"approval_reference": self.approval_reference,
}
def plan_recovery_operation(
session: Session,
*,
installation_id: str,
module_id: str,
operation_type: str,
idempotency_key: str,
request: dict[str, Any],
recovery_plan: RecoveryPlan,
resource_type: str | None = None,
resource_id: str | None = None,
lease_claim: LeaseClaim | None = None,
metadata: dict[str, Any] | None = None,
) -> RecoveryOperation:
recovery_plan.validate()
request_sha256 = _canonical_sha256(request)
existing = session.execute(
select(RecoveryOperation).where(
RecoveryOperation.installation_id == installation_id,
RecoveryOperation.module_id == module_id,
RecoveryOperation.idempotency_key == idempotency_key,
)
).scalar_one_or_none()
if existing is not None:
if existing.request_sha256 != request_sha256:
raise RecoveryIdempotencyConflict(
"Recovery operation idempotency key was reused for another request"
)
return existing
if contains_plain_secret(metadata or {}):
raise RecoveryGuaranteeError(
"Recovery metadata must contain secret references, not plaintext secrets"
)
if lease_claim is not None:
if lease_claim.installation_id != installation_id:
raise RecoveryGuaranteeError(
"Recovery operation and lease belong to different installations"
)
assert_lease_fence(session, lease_claim)
operation = RecoveryOperation(
installation_id=installation_id,
module_id=module_id,
operation_type=operation_type,
resource_type=resource_type,
resource_id=resource_id,
mode=recovery_plan.mode.value,
status=RecoveryStatus.PLANNED.value,
idempotency_key=idempotency_key,
request_sha256=request_sha256,
plan=recovery_plan.as_dict(),
backup_reference=recovery_plan.backup_reference,
approval_reference=recovery_plan.approval_reference,
lease_resource_key=lease_claim.resource_key if lease_claim else None,
holder_node_id=lease_claim.holder_node_id if lease_claim else None,
holder_incarnation=lease_claim.holder_incarnation if lease_claim else None,
fencing_token=lease_claim.fencing_token if lease_claim else None,
metadata_=dict(metadata or {}),
)
try:
with session.begin_nested():
session.add(operation)
session.flush()
except IntegrityError:
existing = session.execute(
select(RecoveryOperation).where(
RecoveryOperation.installation_id == installation_id,
RecoveryOperation.module_id == module_id,
RecoveryOperation.idempotency_key == idempotency_key,
)
).scalar_one_or_none()
if existing is None:
raise
if existing.request_sha256 != request_sha256:
raise RecoveryIdempotencyConflict(
"Recovery operation idempotency key was reused for another request"
)
return existing
record_recovery_checkpoint(
session,
operation,
kind="plan",
summary="Recovery contract recorded before side effects",
evidence={"request_sha256": request_sha256, "plan": recovery_plan.as_dict()},
lease_claim=lease_claim,
)
return operation
def prepare_recovery_operation(
session: Session,
operation: RecoveryOperation,
*,
evidence: dict[str, Any],
lease_claim: LeaseClaim | None = None,
) -> RecoveryOperation:
_verify_operation_fence(session, operation, lease_claim)
if not evidence:
raise RecoveryGuaranteeError(
"Recovery preparation requires durable precondition evidence"
)
return transition_recovery_operation(
session,
operation,
status=RecoveryStatus.PREPARED,
kind="prepared",
summary="Preconditions and recovery material verified",
evidence=evidence,
lease_claim=lease_claim,
)
def start_recovery_operation(
session: Session,
operation: RecoveryOperation,
*,
evidence: dict[str, Any] | None = None,
lease_claim: LeaseClaim | None = None,
now: datetime | None = None,
) -> RecoveryOperation:
_verify_operation_fence(session, operation, lease_claim)
operation.started_at = _as_utc(now or utcnow())
return transition_recovery_operation(
session,
operation,
status=RecoveryStatus.RUNNING,
kind="started",
summary="Guarded operation started",
evidence=evidence or {},
lease_claim=lease_claim,
)
def transition_recovery_operation(
session: Session,
operation: RecoveryOperation,
*,
status: RecoveryStatus,
kind: str,
summary: str,
evidence: dict[str, Any] | None = None,
failure_summary: str | None = None,
lease_claim: LeaseClaim | None = None,
now: datetime | None = None,
) -> RecoveryOperation:
locked = session.execute(
select(RecoveryOperation)
.where(RecoveryOperation.id == operation.id)
.with_for_update()
).scalar_one()
_verify_operation_fence(session, locked, lease_claim)
allowed = _TRANSITIONS.get(locked.status, frozenset())
if status.value not in allowed:
raise RecoveryGuaranteeError(
f"Recovery transition {locked.status!r} -> {status.value!r} is not allowed"
)
_validate_mode_transition(locked, status)
_validate_transition_evidence(
status=status,
evidence=evidence or {},
failure_summary=failure_summary,
)
observed_at = _as_utc(now or utcnow())
locked.status = status.value
locked.revision = int(locked.revision or 0) + 1
if failure_summary is not None:
locked.failure_summary = failure_summary
if status == RecoveryStatus.SUCCEEDED:
locked.completed_at = observed_at
elif status == RecoveryStatus.RECOVERING:
locked.recovery_started_at = observed_at
elif status == RecoveryStatus.RECOVERED:
locked.recovered_at = observed_at
locked.completed_at = observed_at
elif status in {RecoveryStatus.FAILED, RecoveryStatus.MANUAL_INTERVENTION}:
locked.completed_at = observed_at
session.add(locked)
record_recovery_checkpoint(
session,
locked,
kind=kind,
summary=summary,
evidence=evidence or {},
lease_claim=lease_claim,
now=observed_at,
)
session.flush()
return locked
def record_recovery_checkpoint(
session: Session,
operation: RecoveryOperation,
*,
kind: str,
summary: str,
evidence: dict[str, Any],
lease_claim: LeaseClaim | None = None,
now: datetime | None = None,
) -> RecoveryCheckpoint:
if contains_plain_secret(evidence):
raise RecoveryGuaranteeError(
"Recovery evidence must contain secret references, not plaintext secrets"
)
locked = session.execute(
select(RecoveryOperation)
.where(RecoveryOperation.id == operation.id)
.with_for_update()
).scalar_one()
_verify_operation_fence(session, locked, lease_claim)
observed_at = _as_utc(now or utcnow())
sequence = int(locked.checkpoint_count or 0) + 1
payload = {
"operation_id": locked.id,
"sequence": sequence,
"status": locked.status,
"kind": kind,
"summary": summary,
"evidence": evidence,
"previous_sha256": locked.evidence_head_sha256,
"created_at": observed_at.isoformat(),
}
checkpoint_hash = _canonical_sha256(payload)
checkpoint = RecoveryCheckpoint(
operation_id=locked.id,
sequence=sequence,
status=locked.status,
kind=kind,
summary=summary,
evidence=dict(evidence),
previous_sha256=locked.evidence_head_sha256,
checkpoint_sha256=checkpoint_hash,
created_at=observed_at,
)
locked.checkpoint_count = sequence
locked.evidence_head_sha256 = checkpoint_hash
session.add(locked)
session.add(checkpoint)
session.flush()
return checkpoint
def verify_recovery_evidence_chain(
session: Session,
operation_id: str,
) -> bool:
operation = session.get(RecoveryOperation, operation_id)
if operation is None:
raise RecoveryGuaranteeError("Recovery operation was not found")
checkpoints = (
session.execute(
select(RecoveryCheckpoint)
.where(RecoveryCheckpoint.operation_id == operation_id)
.order_by(RecoveryCheckpoint.sequence)
)
.scalars()
.all()
)
previous: str | None = None
for expected_sequence, checkpoint in enumerate(checkpoints, start=1):
if (
checkpoint.sequence != expected_sequence
or checkpoint.previous_sha256 != previous
):
return False
payload = {
"operation_id": checkpoint.operation_id,
"sequence": checkpoint.sequence,
"status": checkpoint.status,
"kind": checkpoint.kind,
"summary": checkpoint.summary,
"evidence": checkpoint.evidence,
"previous_sha256": checkpoint.previous_sha256,
"created_at": _as_utc(checkpoint.created_at).isoformat(),
}
if _canonical_sha256(payload) != checkpoint.checkpoint_sha256:
return False
previous = checkpoint.checkpoint_sha256
return (
len(checkpoints) == int(operation.checkpoint_count or 0)
and previous == operation.evidence_head_sha256
)
def operation_recovery_action(mode: RecoveryMode) -> RecoveryStatus:
if mode == RecoveryMode.ATOMIC:
return RecoveryStatus.FAILED
if mode in {
RecoveryMode.COMPENSATION,
RecoveryMode.SNAPSHOT_RESTORE,
RecoveryMode.FORWARD_RECOVERY,
}:
return RecoveryStatus.RECOVERY_REQUIRED
return RecoveryStatus.MANUAL_INTERVENTION
def _validate_mode_transition(
operation: RecoveryOperation,
status: RecoveryStatus,
) -> None:
mode = RecoveryMode(operation.mode)
if (
operation.status == RecoveryStatus.RUNNING.value
and status == RecoveryStatus.FAILED
and mode != RecoveryMode.ATOMIC
):
raise RecoveryGuaranteeError(
"A non-atomic operation cannot be marked failed after it starts; "
"record recovery_required, outcome_unknown, or manual_intervention"
)
if mode == RecoveryMode.ATOMIC and status in {
RecoveryStatus.RECOVERY_REQUIRED,
RecoveryStatus.RECOVERING,
RecoveryStatus.RECOVERED,
}:
raise RecoveryGuaranteeError(
"Atomic operations must roll back in their transaction instead of entering recovery"
)
if mode == RecoveryMode.IRREVERSIBLE and status in {
RecoveryStatus.RECOVERY_REQUIRED,
RecoveryStatus.RECOVERING,
RecoveryStatus.RECOVERED,
}:
raise RecoveryGuaranteeError(
"Irreversible operations cannot claim automated recovery"
)
def _validate_transition_evidence(
*,
status: RecoveryStatus,
evidence: dict[str, Any],
failure_summary: str | None,
) -> None:
if status in {RecoveryStatus.SUCCEEDED, RecoveryStatus.RECOVERED}:
checks = evidence.get("checks")
if (
evidence.get("verified") is not True
or not isinstance(
checks,
(dict, list),
)
or not checks
):
raise RecoveryGuaranteeError(
"Successful recovery transitions require verified evidence and check results"
)
if status == RecoveryStatus.MANUAL_INTERVENTION and not failure_summary:
raise RecoveryGuaranteeError(
"Manual intervention requires an operator-facing failure summary"
)
def _verify_operation_fence(
session: Session,
operation: RecoveryOperation,
lease_claim: LeaseClaim | None,
) -> None:
if operation.lease_resource_key is None:
return
if lease_claim is None:
raise RecoveryGuaranteeError(
"This recovery operation requires its distributed lease fence"
)
if (
lease_claim.resource_key != operation.lease_resource_key
or lease_claim.holder_node_id != operation.holder_node_id
or lease_claim.holder_incarnation != operation.holder_incarnation
or lease_claim.fencing_token != operation.fencing_token
):
raise RecoveryGuaranteeError(
"Recovery operation lease does not match its recorded fence"
)
assert_lease_fence(session, lease_claim)
def _canonical_sha256(value: dict[str, Any]) -> str:
encoded = json.dumps(
value,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
default=str,
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
__all__ = [
"RecoveryCheckpoint",
"RecoveryGuaranteeError",
"RecoveryIdempotencyConflict",
"RecoveryMode",
"RecoveryOperation",
"RecoveryPlan",
"RecoveryStatus",
"TERMINAL_RECOVERY_STATUSES",
"operation_recovery_action",
"plan_recovery_operation",
"prepare_recovery_operation",
"record_recovery_checkpoint",
"start_recovery_operation",
"transition_recovery_operation",
"verify_recovery_evidence_chain",
]
+184
View File
@@ -29,6 +29,12 @@ from govoplan_core.core.ownership import (
OwnershipProviderRegistration,
ResourceOwnershipProvider,
)
from govoplan_core.core.provider_governance import (
ExternalProviderDeclaration,
ExternalProviderStateProviderRegistration,
ModuleArchitectureDeclaration,
module_architecture_issues,
)
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
from govoplan_core.core.search import (
RegisteredSearchProvider,
@@ -163,6 +169,33 @@ class PlatformRegistry:
def manifests(self) -> tuple[ModuleManifest, ...]:
return tuple(self._topologically_sorted())
def module_architectures(
self,
) -> tuple[tuple[str, ModuleArchitectureDeclaration], ...]:
return tuple(
(manifest.id, manifest.architecture)
for manifest in self.manifests()
if manifest.architecture is not None
)
def external_provider_declarations(
self,
) -> tuple[ExternalProviderDeclaration, ...]:
return tuple(
declaration
for manifest in self.manifests()
for declaration in manifest.external_providers
)
def external_provider_state_providers(
self,
) -> tuple[ExternalProviderStateProviderRegistration, ...]:
return tuple(
registration
for manifest in self.manifests()
for registration in manifest.external_provider_state_providers
)
def permissions(self) -> tuple[PermissionDefinition, ...]:
return tuple(permission for manifest in self.manifests() for permission in manifest.permissions)
@@ -629,9 +662,160 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
)
_validate_documentation_extensions(manifest)
_validate_architecture_declarations(manifest)
_validate_workflow_definition_contributions(manifest)
def _validate_architecture_declarations(manifest: ModuleManifest) -> None:
architecture = manifest.architecture
if architecture is not None:
for issue in module_architecture_issues(
architecture,
has_migrations=manifest.migration_spec is not None,
):
raise RegistryError(
f"Module {manifest.id!r} architecture declaration: {issue}"
)
provider_ids: set[str] = set()
declared_capabilities = {
*manifest.required_capabilities,
*manifest.optional_capabilities,
*manifest.capability_factories,
}
declared_interfaces = {
*(item.name for item in manifest.provides_interfaces),
*(item.name for item in manifest.requires_interfaces),
}
operational_check_ids = {
item.check_id for item in manifest.operational_check_providers
}
documentation_topic_ids = {item.id for item in manifest.documentation}
ownership_resource_types = {
item.resource_type for item in manifest.ownership_providers
}
provider_authority_modes: set[str] = set()
for declaration in manifest.external_providers:
if declaration.module_id != manifest.id:
raise RegistryError(
f"Provider declaration {declaration.id!r} belongs to "
f"{declaration.module_id!r}, not module {manifest.id!r}"
)
if declaration.id in provider_ids:
raise RegistryError(
f"Module {manifest.id!r} declares duplicate external provider "
f"{declaration.id!r}"
)
provider_ids.add(declaration.id)
provider_authority_modes.update(declaration.authority_modes)
_validate_provider_references(
manifest.id,
declaration,
declared_capabilities=declared_capabilities,
declared_interfaces=declared_interfaces,
operational_check_ids=operational_check_ids,
documentation_topic_ids=documentation_topic_ids,
ownership_resource_types=ownership_resource_types,
)
state_provider_ids: set[str] = set()
for registration in manifest.external_provider_state_providers:
if registration.module_id != manifest.id:
raise RegistryError(
f"Provider state registration {registration.provider_id!r} belongs "
f"to {registration.module_id!r}, not module {manifest.id!r}"
)
if registration.provider_id not in provider_ids:
raise RegistryError(
f"Module {manifest.id!r} registers runtime state for undeclared "
f"provider {registration.provider_id!r}"
)
if registration.provider_id in state_provider_ids:
raise RegistryError(
f"Module {manifest.id!r} registers duplicate runtime state for "
f"provider {registration.provider_id!r}"
)
state_provider_ids.add(registration.provider_id)
missing_state_provider_ids = provider_ids - state_provider_ids
if missing_state_provider_ids:
raise RegistryError(
f"Module {manifest.id!r} external providers require sanitized runtime "
"state providers: " + ", ".join(sorted(missing_state_provider_ids))
)
if architecture is None:
if manifest.external_providers or manifest.external_provider_state_providers:
raise RegistryError(
f"Module {manifest.id!r} declares external providers without a "
"module architecture declaration"
)
return
missing_modes = provider_authority_modes - set(
architecture.supported_authority_modes
)
if missing_modes:
raise RegistryError(
f"Module {manifest.id!r} provider authority modes are not declared "
"by its architecture metadata: " + ", ".join(sorted(missing_modes))
)
unknown_target_providers = set(
architecture.target_tested_providers
) - provider_ids
if unknown_target_providers:
raise RegistryError(
f"Module {manifest.id!r} target-tested providers are not declared: "
+ ", ".join(sorted(unknown_target_providers))
)
def _validate_provider_references(
module_id: str,
declaration: ExternalProviderDeclaration,
*,
declared_capabilities: set[str],
declared_interfaces: set[str],
operational_check_ids: set[str],
documentation_topic_ids: set[str],
ownership_resource_types: set[str],
) -> None:
references = (
(
"capabilities",
set(declaration.capability_names),
declared_capabilities,
),
(
"interfaces",
set(declaration.interface_names),
declared_interfaces,
),
(
"operational checks",
set(declaration.operational_check_ids),
operational_check_ids,
),
(
"documentation topics",
set(declaration.documentation_topic_ids),
documentation_topic_ids,
),
(
"ownership resource types",
set(declaration.ownership_resource_types),
ownership_resource_types,
),
)
for label, requested, available in references:
missing = requested - available
if missing:
raise RegistryError(
f"Module {module_id!r} provider {declaration.id!r} references "
f"undeclared {label}: " + ", ".join(sorted(missing))
)
def _validate_workflow_definition_contributions(
manifest: ModuleManifest,
) -> None:
@@ -0,0 +1,605 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import StrEnum
import hashlib
import json
import os
import socket
from typing import Any
from uuid import uuid4
from sqlalchemy import BigInteger, DateTime, Index, Integer, JSON, String, UniqueConstraint, select
from sqlalchemy.orm import Mapped, Session, mapped_column
from govoplan_core.db.base import Base, TimestampMixin, utcnow
class RuntimeNodeState(StrEnum):
ACTIVE = "active"
DRAINING = "draining"
STOPPED = "stopped"
class RuntimeCoordinationError(RuntimeError):
pass
class LeaseUnavailable(RuntimeCoordinationError):
pass
class StaleFence(RuntimeCoordinationError):
pass
class RuntimeNode(Base, TimestampMixin):
__tablename__ = "core_runtime_nodes"
__table_args__ = (
UniqueConstraint(
"installation_id",
"node_id",
name="uq_core_runtime_node_installation_node",
),
Index(
"ix_core_runtime_nodes_installation_state_heartbeat",
"installation_id",
"state",
"last_heartbeat_at",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid4()),
)
installation_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
node_id: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
incarnation: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
role: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
software_version: Mapped[str] = mapped_column(String(80), nullable=False)
composition_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
queues: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
state: Mapped[str] = mapped_column(
String(30),
default=RuntimeNodeState.ACTIVE.value,
nullable=False,
index=True,
)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
last_heartbeat_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
drain_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
drain_reason: Mapped[str | None] = mapped_column(String(500))
stopped_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, default=dict, nullable=False)
class DistributedLease(Base, TimestampMixin):
__tablename__ = "core_distributed_leases"
__table_args__ = (
UniqueConstraint(
"installation_id",
"resource_key",
name="uq_core_distributed_lease_resource",
),
Index(
"ix_core_distributed_leases_expiry",
"installation_id",
"expires_at",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid4()),
)
installation_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
resource_key: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
holder_node_id: Mapped[str | None] = mapped_column(String(200), index=True)
holder_incarnation: Mapped[str | None] = mapped_column(String(36), index=True)
fencing_token: Mapped[int] = mapped_column(
BigInteger().with_variant(Integer, "sqlite"),
default=0,
nullable=False,
)
acquired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
renewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, default=dict, nullable=False)
@dataclass(frozen=True, slots=True)
class RuntimeIdentity:
installation_id: str
node_id: str
incarnation: str
role: str
software_version: str
composition_hash: str
queues: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class LeaseClaim:
installation_id: str
resource_key: str
holder_node_id: str
holder_incarnation: str
fencing_token: int
expires_at: datetime
def runtime_identity(
settings: object,
*,
software_version: str,
module_ids: tuple[str, ...] = (),
role: str | None = None,
node_id: str | None = None,
incarnation: str | None = None,
queues: tuple[str, ...] | None = None,
) -> RuntimeIdentity:
effective_role = str(
role or getattr(settings, "runtime_role", "api") or "api"
).strip().lower()
effective_node_id = str(
node_id
or getattr(settings, "runtime_node_id", None)
or os.getenv("HOSTNAME")
or socket.gethostname()
).strip()
installation_id = str(
getattr(settings, "installation_id", "govoplan-local")
or "govoplan-local"
).strip()
if not installation_id or not effective_node_id or not effective_role:
raise RuntimeCoordinationError(
"Runtime identity requires installation, node, and role identifiers"
)
effective_queues = queues
if effective_queues is None:
effective_queues = tuple(
item.strip()
for item in str(getattr(settings, "celery_queues", "") or "").split(",")
if item.strip()
)
composition_hash = hashlib.sha256(
json.dumps(sorted(module_ids), separators=(",", ":")).encode("utf-8")
).hexdigest()
return RuntimeIdentity(
installation_id=installation_id,
node_id=effective_node_id,
incarnation=incarnation or str(uuid4()),
role=effective_role,
software_version=str(software_version),
composition_hash=composition_hash,
queues=tuple(sorted(set(effective_queues))),
)
def register_runtime_node(
session: Session,
identity: RuntimeIdentity,
*,
metadata: dict[str, Any] | None = None,
now: datetime | None = None,
) -> RuntimeNode:
observed_at = _as_utc(now or utcnow())
_insert_node_placeholder(session, identity, observed_at)
node = _locked_node(session, identity.installation_id, identity.node_id)
if node is None: # pragma: no cover - guarded by insert/upsert
raise RuntimeCoordinationError("Runtime node registration was not persisted")
node.incarnation = identity.incarnation
node.role = identity.role
node.software_version = identity.software_version
node.composition_hash = identity.composition_hash
node.queues = list(identity.queues)
node.state = RuntimeNodeState.ACTIVE.value
node.started_at = observed_at
node.last_heartbeat_at = observed_at
node.drain_requested_at = None
node.drain_reason = None
node.stopped_at = None
node.metadata_ = dict(metadata or {})
session.add(node)
session.flush()
return node
def heartbeat_runtime_node(
session: Session,
identity: RuntimeIdentity,
*,
now: datetime | None = None,
metadata: dict[str, Any] | None = None,
) -> RuntimeNode:
node = _locked_node(session, identity.installation_id, identity.node_id)
if node is None or node.incarnation != identity.incarnation:
raise RuntimeCoordinationError(
"Runtime heartbeat was rejected for a stale node incarnation"
)
if node.state == RuntimeNodeState.STOPPED.value:
raise RuntimeCoordinationError("Stopped runtime node cannot heartbeat")
node.last_heartbeat_at = _as_utc(now or utcnow())
if metadata is not None:
node.metadata_ = dict(metadata)
session.add(node)
session.flush()
return node
def request_runtime_node_drain(
session: Session,
*,
installation_id: str,
node_id: str,
reason: str | None = None,
now: datetime | None = None,
) -> RuntimeNode:
node = _locked_node(session, installation_id, node_id)
if node is None:
raise RuntimeCoordinationError("Runtime node was not found")
if node.state == RuntimeNodeState.STOPPED.value:
raise RuntimeCoordinationError("Stopped runtime node cannot be drained")
node.state = RuntimeNodeState.DRAINING.value
node.drain_requested_at = _as_utc(now or utcnow())
node.drain_reason = str(reason or "operator request")[:500]
session.add(node)
session.flush()
return node
def cancel_runtime_node_drain(
session: Session,
*,
installation_id: str,
node_id: str,
) -> RuntimeNode:
node = _locked_node(session, installation_id, node_id)
if node is None:
raise RuntimeCoordinationError("Runtime node was not found")
if node.state != RuntimeNodeState.DRAINING.value:
return node
node.state = RuntimeNodeState.ACTIVE.value
node.drain_requested_at = None
node.drain_reason = None
session.add(node)
session.flush()
return node
def stop_runtime_node(
session: Session,
identity: RuntimeIdentity,
*,
now: datetime | None = None,
) -> bool:
node = _locked_node(session, identity.installation_id, identity.node_id)
if node is None or node.incarnation != identity.incarnation:
return False
observed_at = _as_utc(now or utcnow())
node.state = RuntimeNodeState.STOPPED.value
node.stopped_at = observed_at
node.last_heartbeat_at = observed_at
session.add(node)
session.flush()
return True
def runtime_node_is_draining(
session: Session,
identity: RuntimeIdentity,
) -> bool:
node = session.execute(
select(RuntimeNode).where(
RuntimeNode.installation_id == identity.installation_id,
RuntimeNode.node_id == identity.node_id,
RuntimeNode.incarnation == identity.incarnation,
)
).scalar_one_or_none()
return node is not None and node.state == RuntimeNodeState.DRAINING.value
def list_runtime_nodes(
session: Session,
*,
installation_id: str,
stale_after_seconds: int,
now: datetime | None = None,
) -> list[dict[str, Any]]:
observed_at = _as_utc(now or utcnow())
stale_before = observed_at - timedelta(seconds=max(1, stale_after_seconds))
nodes = session.execute(
select(RuntimeNode)
.where(RuntimeNode.installation_id == installation_id)
.order_by(RuntimeNode.role, RuntimeNode.node_id)
).scalars().all()
return [
{
"node_id": node.node_id,
"incarnation": node.incarnation,
"role": node.role,
"software_version": node.software_version,
"composition_hash": node.composition_hash,
"queues": list(node.queues or []),
"state": node.state,
"started_at": _iso(node.started_at),
"last_heartbeat_at": _iso(node.last_heartbeat_at),
"drain_requested_at": _iso(node.drain_requested_at),
"drain_reason": node.drain_reason,
"stopped_at": _iso(node.stopped_at),
"stale": (
node.state != RuntimeNodeState.STOPPED.value
and _as_utc(node.last_heartbeat_at) < stale_before
),
"metadata": dict(node.metadata_ or {}),
}
for node in nodes
]
def acquire_lease(
session: Session,
*,
installation_id: str,
resource_key: str,
holder_node_id: str,
holder_incarnation: str,
ttl_seconds: int,
metadata: dict[str, Any] | None = None,
now: datetime | None = None,
) -> LeaseClaim | None:
if ttl_seconds < 1:
raise ValueError("Lease TTL must be at least one second")
observed_at = _as_utc(now or utcnow())
_insert_lease_placeholder(session, installation_id, resource_key, observed_at)
lease = _locked_lease(session, installation_id, resource_key)
if lease is None: # pragma: no cover - guarded by insert/upsert
raise RuntimeCoordinationError("Distributed lease was not persisted")
same_holder = (
lease.holder_node_id == holder_node_id
and lease.holder_incarnation == holder_incarnation
)
expired = _as_utc(lease.expires_at) <= observed_at
if lease.holder_node_id is not None and not same_holder and not expired:
return None
if not same_holder or expired:
lease.fencing_token = int(lease.fencing_token or 0) + 1
lease.acquired_at = observed_at
lease.holder_node_id = holder_node_id
lease.holder_incarnation = holder_incarnation
lease.renewed_at = observed_at
lease.expires_at = observed_at + timedelta(seconds=ttl_seconds)
lease.metadata_ = dict(metadata or {})
session.add(lease)
session.flush()
return _lease_claim(lease)
def renew_lease(
session: Session,
claim: LeaseClaim,
*,
ttl_seconds: int,
now: datetime | None = None,
) -> LeaseClaim:
if ttl_seconds < 1:
raise ValueError("Lease TTL must be at least one second")
observed_at = _as_utc(now or utcnow())
lease = _locked_lease(session, claim.installation_id, claim.resource_key)
_assert_matching_fence(lease, claim, observed_at=observed_at)
lease.renewed_at = observed_at
lease.expires_at = observed_at + timedelta(seconds=ttl_seconds)
session.add(lease)
session.flush()
return _lease_claim(lease)
def release_lease(
session: Session,
claim: LeaseClaim,
*,
now: datetime | None = None,
) -> None:
observed_at = _as_utc(now or utcnow())
lease = _locked_lease(session, claim.installation_id, claim.resource_key)
_assert_matching_fence(lease, claim, observed_at=observed_at)
lease.holder_node_id = None
lease.holder_incarnation = None
lease.renewed_at = observed_at
lease.expires_at = observed_at
session.add(lease)
session.flush()
def assert_lease_fence(
session: Session,
claim: LeaseClaim,
*,
now: datetime | None = None,
) -> None:
lease = _locked_lease(session, claim.installation_id, claim.resource_key)
_assert_matching_fence(
lease,
claim,
observed_at=_as_utc(now or utcnow()),
)
def _insert_node_placeholder(
session: Session,
identity: RuntimeIdentity,
observed_at: datetime,
) -> None:
values = {
"id": str(uuid4()),
"installation_id": identity.installation_id,
"node_id": identity.node_id,
"incarnation": identity.incarnation,
"role": identity.role,
"software_version": identity.software_version,
"composition_hash": identity.composition_hash,
"queues": list(identity.queues),
"state": RuntimeNodeState.ACTIVE.value,
"started_at": observed_at,
"last_heartbeat_at": observed_at,
"metadata": {},
"created_at": observed_at,
"updated_at": observed_at,
}
_insert_do_nothing(
session,
RuntimeNode.__table__,
values,
conflict_columns=("installation_id", "node_id"),
)
def _insert_lease_placeholder(
session: Session,
installation_id: str,
resource_key: str,
observed_at: datetime,
) -> None:
values = {
"id": str(uuid4()),
"installation_id": installation_id,
"resource_key": resource_key,
"fencing_token": 0,
"expires_at": observed_at,
"metadata": {},
"created_at": observed_at,
"updated_at": observed_at,
}
_insert_do_nothing(
session,
DistributedLease.__table__,
values,
conflict_columns=("installation_id", "resource_key"),
)
def _insert_do_nothing(
session: Session,
table: Any,
values: dict[str, Any],
*,
conflict_columns: tuple[str, ...],
) -> None:
dialect = session.get_bind().dialect.name
if dialect == "postgresql":
from sqlalchemy.dialects.postgresql import insert
statement = insert(table).values(**values).on_conflict_do_nothing(
index_elements=list(conflict_columns)
)
elif dialect == "sqlite":
from sqlalchemy.dialects.sqlite import insert
statement = insert(table).values(**values).on_conflict_do_nothing(
index_elements=list(conflict_columns)
)
else: # pragma: no cover - GovOPlaN supports PostgreSQL and dev SQLite
raise RuntimeCoordinationError(
f"Distributed coordination is unsupported on {dialect!r}"
)
session.execute(statement)
session.flush()
def _locked_node(
session: Session,
installation_id: str,
node_id: str,
) -> RuntimeNode | None:
return session.execute(
select(RuntimeNode)
.where(
RuntimeNode.installation_id == installation_id,
RuntimeNode.node_id == node_id,
)
.with_for_update()
).scalar_one_or_none()
def _locked_lease(
session: Session,
installation_id: str,
resource_key: str,
) -> DistributedLease | None:
return session.execute(
select(DistributedLease)
.where(
DistributedLease.installation_id == installation_id,
DistributedLease.resource_key == resource_key,
)
.with_for_update()
).scalar_one_or_none()
def _assert_matching_fence(
lease: DistributedLease | None,
claim: LeaseClaim,
*,
observed_at: datetime,
) -> None:
if (
lease is None
or lease.holder_node_id != claim.holder_node_id
or lease.holder_incarnation != claim.holder_incarnation
or int(lease.fencing_token) != claim.fencing_token
or _as_utc(lease.expires_at) <= observed_at
):
raise StaleFence(
f"Lease fence is stale for resource {claim.resource_key!r}"
)
def _lease_claim(lease: DistributedLease) -> LeaseClaim:
if lease.holder_node_id is None or lease.holder_incarnation is None:
raise LeaseUnavailable("Distributed lease has no active holder")
return LeaseClaim(
installation_id=lease.installation_id,
resource_key=lease.resource_key,
holder_node_id=lease.holder_node_id,
holder_incarnation=lease.holder_incarnation,
fencing_token=int(lease.fencing_token),
expires_at=_as_utc(lease.expires_at),
)
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _iso(value: datetime | None) -> str | None:
return _as_utc(value).isoformat() if value is not None else None
__all__ = [
"DistributedLease",
"LeaseClaim",
"LeaseUnavailable",
"RuntimeCoordinationError",
"RuntimeIdentity",
"RuntimeNode",
"RuntimeNodeState",
"StaleFence",
"acquire_lease",
"assert_lease_fence",
"cancel_runtime_node_drain",
"heartbeat_runtime_node",
"list_runtime_nodes",
"register_runtime_node",
"release_lease",
"renew_lease",
"request_runtime_node_drain",
"runtime_identity",
"runtime_node_is_draining",
"stop_runtime_node",
]
+2
View File
@@ -32,6 +32,8 @@ 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 recovery as core_recovery_models # noqa: F401
from govoplan_core.core import runtime_coordination as core_runtime_models # noqa: F401
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
from contextlib import contextmanager
import hashlib
import time
from collections.abc import Iterator
from sqlalchemy import create_engine, text
from sqlalchemy.engine import make_url
from sqlalchemy.exc import SQLAlchemyError
class MigrationLockTimeout(RuntimeError):
pass
def migration_lock_key(installation_id: str, migration_track: str) -> int:
payload = f"govoplan\0{installation_id}\0{migration_track}".encode("utf-8")
return int.from_bytes(hashlib.sha256(payload).digest()[:8], "big", signed=True)
@contextmanager
def deployment_migration_lock(
database_url: str,
*,
installation_id: str,
migration_track: str,
timeout_seconds: float = 900.0,
poll_seconds: float = 2.0,
) -> Iterator[None]:
"""Serialize all release migrations before coordination tables are available."""
if timeout_seconds < 0 or poll_seconds <= 0:
raise ValueError("Migration lock timeout must be non-negative and polling positive")
if make_url(database_url).get_backend_name() != "postgresql":
yield
return
key = migration_lock_key(installation_id, migration_track)
engine = create_engine(database_url)
connection = engine.connect()
acquired = False
try:
deadline = time.monotonic() + timeout_seconds
while True:
acquired = bool(
connection.execute(
text("SELECT pg_try_advisory_lock(:key)"),
{"key": key},
).scalar_one()
)
if acquired:
break
if time.monotonic() >= deadline:
raise MigrationLockTimeout(
"Timed out waiting for the deployment-wide database migration lock"
)
time.sleep(min(poll_seconds, max(0.05, deadline - time.monotonic())))
yield
finally:
if acquired:
try:
connection.execute(
text("SELECT pg_advisory_unlock(:key)"),
{"key": key},
)
except SQLAlchemyError:
# Closing the physical connection releases session locks.
pass
connection.close()
engine.dispose()
__all__ = [
"MigrationLockTimeout",
"deployment_migration_lock",
"migration_lock_key",
]
+47
View File
@@ -18,6 +18,8 @@ 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 recovery as core_recovery_models # noqa: F401 - populate core metadata
from govoplan_core.core import runtime_coordination as core_runtime_models # noqa: F401 - populate core metadata
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
@@ -590,6 +592,51 @@ def database_revision(database_url: str | None = None) -> str | None:
engine.dispose()
def database_migration_heads(
database_url: str | None = None,
) -> tuple[str, ...]:
url = database_url or settings.database_url
engine = create_engine(url)
try:
with engine.connect() as connection:
return tuple(
sorted(MigrationContext.configure(connection).get_current_heads())
)
finally:
engine.dispose()
def configured_migration_heads(
database_url: str | None = None,
*,
enabled_modules: tuple[str, ...] | list[str] | None = None,
manifest_factories: tuple[ManifestFactory, ...] = (),
migration_track: str | None = None,
) -> tuple[str, ...]:
config = alembic_config(
database_url=database_url,
enabled_modules=enabled_modules,
manifest_factories=manifest_factories,
migration_track=migration_track,
)
return tuple(sorted(ScriptDirectory.from_config(config).get_heads()))
def database_is_at_configured_heads(
database_url: str | None = None,
*,
enabled_modules: tuple[str, ...] | list[str] | None = None,
manifest_factories: tuple[ManifestFactory, ...] = (),
migration_track: str | None = None,
) -> bool:
return database_migration_heads(database_url) == configured_migration_heads(
database_url,
enabled_modules=enabled_modules,
manifest_factories=manifest_factories,
migration_track=migration_track,
)
def _has_columns(inspector, table_name: str, required: set[str]) -> bool:
try:
actual = {column["name"] for column in inspector.get_columns(table_name)}
+55 -6
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI
from fastapi import HTTPException, status
from sqlalchemy.engine import make_url
from govoplan_core.auth import ApiPrincipal, require_scope
@@ -10,6 +11,7 @@ from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.bootstrap import bootstrap_dev_data, create_all_tables
from govoplan_core.db.session import get_database
from govoplan_core.server.config import GovoplanServerConfig
from govoplan_core.server.runtime_agent import RuntimeNodeAgent
from govoplan_core.settings import Settings, settings
@@ -41,15 +43,36 @@ async def lifespan(app: FastAPI):
"reconcile_workflow_definitions",
):
lifecycle.reconcile_workflow_definitions()
registry = getattr(app.state, "govoplan_registry", None)
module_ids = (
tuple(manifest.id for manifest in registry.manifests())
if registry is not None
else ()
)
runtime_agent = RuntimeNodeAgent(
settings=settings,
software_version=app.version,
module_ids=module_ids,
metadata={"process": "api"},
)
await runtime_agent.start()
app.state.govoplan_runtime_agent = runtime_agent
try:
yield
finally:
await runtime_agent.stop()
def _cors_origins(value: str) -> list[str]:
return [item.strip() for item in value.split(",") if item.strip()]
def register_health_details(app: FastAPI, registry: PlatformRegistry, config_settings: object | None) -> None:
active_settings = config_settings if isinstance(config_settings, Settings) else settings
def register_health_details(
app: FastAPI, registry: PlatformRegistry, config_settings: object | None
) -> None:
active_settings = (
config_settings if isinstance(config_settings, Settings) else settings
)
@app.get("/health/details")
def health_details(
@@ -63,13 +86,39 @@ def register_health_details(app: FastAPI, registry: PlatformRegistry, config_set
"modules": [manifest.id for manifest in registry.manifests()],
"storage": {
"backend": active_settings.file_storage_backend,
"local_root": active_settings.file_storage_local_root if active_settings.file_storage_backend == "local" else None,
"endpoint": active_settings.file_storage_s3_endpoint_url or active_settings.s3_endpoint_url,
"bucket": active_settings.file_storage_s3_bucket or active_settings.s3_bucket,
"region": active_settings.file_storage_s3_region or active_settings.s3_region,
"local_root": active_settings.file_storage_local_root
if active_settings.file_storage_backend == "local"
else None,
"endpoint": active_settings.file_storage_s3_endpoint_url
or active_settings.s3_endpoint_url,
"bucket": active_settings.file_storage_s3_bucket
or active_settings.s3_bucket,
"region": active_settings.file_storage_s3_region
or active_settings.s3_region,
},
}
@app.get("/health/ready")
def health_ready():
runtime_agent = getattr(app.state, "govoplan_runtime_agent", None)
if runtime_agent is not None and not runtime_agent.coordination_healthy:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
"status": "coordination_unavailable",
"node_id": runtime_agent.identity.node_id,
},
)
if runtime_agent is not None and runtime_agent.draining:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
"status": "draining",
"node_id": runtime_agent.identity.node_id,
},
)
return {"status": "ready"}
def get_server_config() -> GovoplanServerConfig:
return GovoplanServerConfig(
+9
View File
@@ -168,6 +168,15 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
"dependencies": list(manifest.dependencies),
"optional_dependencies": list(manifest.optional_dependencies),
"enabled": True,
"architecture": (
manifest.architecture.to_dict()
if manifest.architecture is not None
else None
),
"external_providers": [
declaration.to_dict()
for declaration in manifest.external_providers
],
"runtime_ui_capabilities": _runtime_ui_capabilities(manifest.id, settings, registry),
"nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items],
"frontend": _frontend_payload(manifest),
+129
View File
@@ -0,0 +1,129 @@
from __future__ import annotations
import asyncio
import logging
from typing import Any
from govoplan_core.core.runtime_coordination import (
RuntimeIdentity,
heartbeat_runtime_node,
register_runtime_node,
runtime_identity,
stop_runtime_node,
)
from govoplan_core.db.session import get_database
logger = logging.getLogger("govoplan.runtime")
class RuntimeNodeAgent:
"""Register one process in the shared runtime directory and heartbeat it."""
def __init__(
self,
*,
settings: object,
software_version: str,
module_ids: tuple[str, ...],
role: str | None = None,
node_id: str | None = None,
queues: tuple[str, ...] | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
self.settings = settings
self.identity: RuntimeIdentity = runtime_identity(
settings,
software_version=software_version,
module_ids=module_ids,
role=role,
node_id=node_id,
queues=queues,
)
self.metadata = dict(metadata or {})
self.draining = False
self.coordination_healthy = False
self._task: asyncio.Task[None] | None = None
self._stopping = False
@property
def heartbeat_seconds(self) -> int:
return max(
2,
int(getattr(self.settings, "runtime_heartbeat_seconds", 15)),
)
async def start(self) -> None:
await asyncio.to_thread(self._register)
self._task = asyncio.create_task(
self._heartbeat_loop(),
name=f"govoplan-runtime-heartbeat:{self.identity.node_id}",
)
async def stop(self) -> None:
self._stopping = True
task = self._task
self._task = None
if task is not None:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
try:
await asyncio.to_thread(self._mark_stopped)
except Exception: # noqa: BLE001 - shutdown must continue
logger.exception(
"runtime node stop marker failed node_id=%s",
self.identity.node_id,
)
def _register(self) -> None:
with get_database().SessionLocal() as session:
node = register_runtime_node(
session,
self.identity,
metadata=self.metadata,
)
session.commit()
self.draining = node.state == "draining"
self.coordination_healthy = True
def _heartbeat(self) -> None:
with get_database().SessionLocal() as session:
node = heartbeat_runtime_node(
session,
self.identity,
metadata=self.metadata,
)
session.commit()
self.draining = node.state == "draining"
self.coordination_healthy = True
def _mark_stopped(self) -> None:
with get_database().SessionLocal() as session:
stop_runtime_node(session, self.identity)
session.commit()
async def _heartbeat_loop(self) -> None:
while not self._stopping:
await asyncio.sleep(self.heartbeat_seconds)
await self._heartbeat_once()
async def _heartbeat_once(self) -> bool:
try:
await asyncio.to_thread(self._heartbeat)
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 - a later heartbeat can recover
self.coordination_healthy = False
logger.exception(
"runtime heartbeat failed node_id=%s",
self.identity.node_id,
)
return False
self.coordination_healthy = True
return True
__all__ = ["RuntimeNodeAgent"]
+38
View File
@@ -6,6 +6,40 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=None, extra="ignore")
app_env: str = Field(default="dev", alias="APP_ENV")
installation_id: str = Field(
default="govoplan-local",
alias="GOVOPLAN_INSTALLATION_ID",
)
state_profile: str = Field(
default="local",
alias="GOVOPLAN_STATE_PROFILE",
)
runtime_role: str = Field(default="api", alias="GOVOPLAN_RUNTIME_ROLE")
runtime_node_id: str | None = Field(default=None, alias="GOVOPLAN_NODE_ID")
runtime_heartbeat_seconds: int = Field(
default=15,
ge=2,
le=300,
alias="GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS",
)
runtime_stale_after_seconds: int = Field(
default=60,
ge=10,
le=3600,
alias="GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS",
)
runtime_expected_api_replicas: int = Field(
default=1,
ge=1,
le=1000,
alias="GOVOPLAN_EXPECTED_API_REPLICAS",
)
runtime_expected_worker_replicas: int = Field(
default=0,
ge=0,
le=1000,
alias="GOVOPLAN_EXPECTED_WORKER_REPLICAS",
)
module_live_apply_enabled: bool | None = Field(
default=None,
alias="GOVOPLAN_MODULE_LIVE_APPLY_ENABLED",
@@ -78,6 +112,10 @@ class Settings(BaseSettings):
default=False,
alias="FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
)
file_storage_s3_endpoint_trusted: bool = Field(
default=False,
alias="FILE_STORAGE_S3_ENDPOINT_TRUSTED",
)
file_upload_max_bytes: int = Field(default=50 * 1024 * 1024, alias="FILE_UPLOAD_MAX_BYTES")
file_upload_zip_max_bytes: int = Field(default=250 * 1024 * 1024, alias="FILE_UPLOAD_ZIP_MAX_BYTES")
file_archive_max_entries: int = Field(default=10_000, ge=1, alias="FILE_ARCHIVE_MAX_ENTRIES")
@@ -0,0 +1,242 @@
from __future__ import annotations
import unittest
from govoplan_core.core.configuration_packages import (
ConfigurationModuleRequirement,
ConfigurationPackageEvidence,
ConfigurationPackageManifest,
ConfigurationPackageParent,
ConfigurationPreflightContext,
ConfigurationProviderExpectation,
dry_run_configuration_package,
validate_configuration_package_derivation,
)
def _evidence(*kinds: str) -> tuple[ConfigurationPackageEvidence, ...]:
return tuple(
ConfigurationPackageEvidence(
kind=kind, # type: ignore[arg-type]
reference=f"evidence/{kind}.json",
summary=f"Evidence for {kind}.",
checksum=f"sha256:{'a' * 64}",
)
for kind in kinds
)
class ConfigurationPackageArchitectureTests(unittest.TestCase):
def test_legacy_package_defaults_to_product_and_round_trips(self) -> None:
package = ConfigurationPackageManifest.from_mapping(
{"package_id": "example", "name": "Example", "version": "1.0.0"}
)
self.assertEqual(package.package_class, "product")
self.assertEqual(
ConfigurationPackageManifest.from_mapping(package.to_dict()),
package,
)
def test_reference_package_requires_operational_evidence(self) -> None:
with self.assertRaisesRegex(ValueError, "accessibility"):
ConfigurationPackageManifest(
package_id="reference.example",
name="Example reference",
version="1.0.0",
package_class="reference",
evidence=_evidence("target_test", "recovery"),
)
def test_reference_evidence_requires_content_checksums(self) -> None:
with self.assertRaisesRegex(ValueError, "without checksums"):
ConfigurationPackageManifest(
package_id="reference.unbound",
name="Unbound reference",
version="1.0.0",
package_class="reference",
evidence=tuple(
ConfigurationPackageEvidence(
kind=kind, # type: ignore[arg-type]
reference=f"evidence/{kind}.json",
summary=f"{kind} evidence.",
checksum=(
None
if kind == "target_test"
else f"sha256:{'b' * 64}"
),
)
for kind in (
"target_test",
"recovery",
"security",
"operations",
"accessibility",
"privacy",
"documentation",
)
),
)
def test_integration_package_preflight_checks_provider_contract_and_health(self) -> None:
package = ConfigurationPackageManifest(
package_id="integration.example",
name="Example integration",
version="1.0.0",
package_class="integration",
evidence=_evidence(
"target_test", "recovery", "operations", "documentation"
),
provider_expectations=(
ConfigurationProviderExpectation(
provider_id="connectors.example",
authority_mode="external_mirror",
minimum_maturity="read",
),
),
)
healthy = dry_run_configuration_package(
package,
(),
ConfigurationPreflightContext(
external_provider_declarations={
"connectors.example": {
"authority_modes": ["external_mirror"],
"maturity": "synchronize",
}
},
external_provider_states={
"connectors.example": {"health": "healthy"}
},
),
)
unhealthy = dry_run_configuration_package(
package,
(),
ConfigurationPreflightContext(
external_provider_declarations={
"connectors.example": {
"authority_modes": ["external_authoritative"],
"maturity": "discover",
}
},
external_provider_states={
"connectors.example": {"health": "error"}
},
),
)
self.assertFalse(
any(item.severity == "blocker" for item in healthy.diagnostics)
)
self.assertEqual(
{
"external_provider_authority_mode_unsupported",
"external_provider_maturity_insufficient",
"external_provider_unhealthy",
},
{item.code for item in unhealthy.diagnostics},
)
def test_provider_preflight_selects_the_requested_runtime_binding(self) -> None:
package = ConfigurationPackageManifest(
package_id="integration.calendar",
name="Calendar integration",
version="1.0.0",
package_class="integration",
evidence=_evidence(
"target_test", "recovery", "operations", "documentation"
),
provider_expectations=(
ConfigurationProviderExpectation(
provider_id="calendar.caldav_sync",
authority_mode="governed_sync",
minimum_maturity="synchronize",
binding_ref="calendar:sync-source:required",
freshness_expectation="current",
recovery_expectation="ready",
),
),
)
context = ConfigurationPreflightContext(
external_provider_declarations={
"calendar.caldav_sync": {
"authority_modes": ["governed_sync"],
"maturity": "synchronize",
"behavior": {},
}
},
external_provider_states={
"calendar.caldav_sync": {
"health": "warning",
"bindings": [
{
"binding_ref": "calendar:sync-source:other",
"authority_mode": "governed_sync",
"health": "error",
"freshness": "stale",
"recovery": "attention",
},
{
"binding_ref": "calendar:sync-source:required",
"authority_mode": "governed_sync",
"health": "healthy",
"freshness": "current",
"recovery": "ready",
},
],
}
},
)
result = dry_run_configuration_package(package, (), context)
self.assertFalse(
any(item.severity == "blocker" for item in result.diagnostics)
)
def test_derived_package_may_tighten_but_not_remove_parent_constraints(self) -> None:
parent = ConfigurationPackageManifest(
package_id="product.base",
name="Base",
version="1.0.0",
required_modules=(
ConfigurationModuleRequirement("connectors", "1.0.0"),
),
required_capabilities=("connectors.profiles",),
provider_expectations=(
ConfigurationProviderExpectation(
provider_id="connectors.example",
authority_mode="external_mirror",
minimum_maturity="read",
),
),
)
child = ConfigurationPackageManifest(
package_id="sector.example",
name="Sector example",
version="1.0.0",
package_class="sector",
parents=(
ConfigurationPackageParent("product.base", "1.0.0"),
),
evidence=_evidence("documentation"),
)
codes = {
item.code
for item in validate_configuration_package_derivation(child, parent)
}
self.assertEqual(
{
"package_parent_module_constraint_loosened",
"package_parent_capability_constraint_loosened",
"package_parent_provider_constraint_removed",
},
codes,
)
if __name__ == "__main__":
unittest.main()
+15
View File
@@ -15,12 +15,14 @@ class ExternalReferenceContractTests(unittest.TestCase):
object_type="work_package",
object_id="42",
maturity="synchronize",
authority_mode="governed_sync",
canonical_url="https://projects.example.test/work_packages/42",
)
self.assertEqual("openproject-main:work_package:42", reference.identity_key)
self.assertTrue(reference.supports("read"))
self.assertFalse(reference.supports("replace"))
self.assertEqual("governed_sync", reference.to_dict()["authority_mode"])
def test_reference_rejects_credentials_in_urls(self) -> None:
with self.assertRaises(ExternalReferenceValidationError):
@@ -31,6 +33,19 @@ class ExternalReferenceContractTests(unittest.TestCase):
canonical_url="https://user:secret@example.test/wiki/Main_Page",
)
def test_authority_mode_must_match_maturity(self) -> None:
with self.assertRaisesRegex(
ExternalReferenceValidationError,
"Governed-sync references require synchronize maturity",
):
ExternalObjectReference(
system="openproject",
object_type="work_package",
object_id="7",
maturity="read",
authority_mode="governed_sync",
)
if __name__ == "__main__":
unittest.main()
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
from datetime import datetime, timezone
import subprocess
from govoplan_core.commands import fenced_run
from govoplan_core.core.runtime_coordination import LeaseClaim
class _IgnoringProcess:
def __init__(self) -> None:
self.terminated = False
self.killed = False
self.wait_count = 0
def poll(self):
return None if not self.killed else -9
def terminate(self) -> None:
self.terminated = True
def kill(self) -> None:
self.killed = True
def wait(self, *, timeout: float):
self.wait_count += 1
if not self.killed:
raise subprocess.TimeoutExpired("command", timeout)
return -9
def test_fenced_process_is_killed_when_it_ignores_termination() -> None:
process = _IgnoringProcess()
fenced_run._terminate_process(process, timeout_seconds=0.01)
assert process.terminated is True
assert process.killed is True
assert process.wait_count == 2
def test_lease_release_is_best_effort_after_database_loss(monkeypatch) -> None:
claim = LeaseClaim(
installation_id="installation-1",
resource_key="scheduler",
holder_node_id="scheduler-1",
holder_incarnation="incarnation-1",
fencing_token=1,
expires_at=datetime(2026, 8, 1, 12, 0, tzinfo=timezone.utc),
)
monkeypatch.setattr(
fenced_run,
"get_database",
lambda: (_ for _ in ()).throw(RuntimeError("database unavailable")),
)
fenced_run._release(claim)
+43
View File
@@ -186,6 +186,44 @@ class InstallConfigTests(unittest.TestCase):
).file_storage_s3_deployment_managed
)
def test_external_s3_requires_explicit_https_deployment_trust(self) -> None:
base = {
"APP_ENV": "development",
"FILE_STORAGE_BACKEND": "s3",
"FILE_STORAGE_S3_ENDPOINT_URL": "https://s3.example.test",
"FILE_STORAGE_S3_REGION": "eu-test-1",
"FILE_STORAGE_S3_ACCESS_KEY_ID": "access",
"FILE_STORAGE_S3_SECRET_ACCESS_KEY": "secret",
"FILE_STORAGE_S3_BUCKET": "files",
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED": "false",
}
rejected = validate_runtime_configuration(base, profile="development")
accepted = validate_runtime_configuration(
{**base, "FILE_STORAGE_S3_ENDPOINT_TRUSTED": "true"},
profile="development",
)
insecure = validate_runtime_configuration(
{
**base,
"FILE_STORAGE_S3_ENDPOINT_URL": "http://s3.example.test",
"FILE_STORAGE_S3_ENDPOINT_TRUSTED": "true",
},
profile="development",
)
self.assertIn(
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
{issue.key for issue in rejected.errors},
)
self.assertNotIn(
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
{issue.key for issue in accepted.errors},
)
self.assertIn(
"FILE_STORAGE_S3_ENDPOINT_URL",
{issue.key for issue in insecure.errors},
)
def test_connector_deployment_allowlists_require_exact_names_and_absolute_paths(self) -> None:
result = validate_runtime_configuration(
{
@@ -215,6 +253,7 @@ class InstallConfigTests(unittest.TestCase):
self.assertIn("GOVOPLAN_HTTP_HSTS_SECONDS=31536000", self_hosted)
self.assertIn("AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10", self_hosted)
self.assertIn("FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false", self_hosted)
self.assertIn("FILE_STORAGE_S3_ENDPOINT_TRUSTED=false", self_hosted)
self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like)
self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", production_like)
@@ -229,6 +268,10 @@ class InstallConfigTests(unittest.TestCase):
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false",
production_like,
)
self.assertIn(
"FILE_STORAGE_S3_ENDPOINT_TRUSTED=false",
production_like,
)
if __name__ == "__main__":
+816
View File
@@ -0,0 +1,816 @@
from __future__ import annotations
from dataclasses import replace
from datetime import UTC, datetime, timedelta
import unittest
from govoplan_core.core.automation import ActionExecutionRequest, AutomationInvocation
from govoplan_core.core.events import PlatformEvent
from govoplan_core.core.external_references import ExternalObjectReference
from govoplan_core.core.institutional import (
ActorRepresentationReference,
DecisionEffectReference,
EvidenceReference,
ExternalSourceReference,
FormDefinition,
FormFieldDefinition,
FormalDecision,
GovernedContextEnvelope,
InformationGovernanceReference,
InstitutionalContextError,
InstitutionalReference,
LegalBasisReference,
MandateDefinition,
MandateResolution,
MandateResolutionRequest,
PartyRepresentation,
PartySubjectReference,
ProcedureParty,
ServiceBinding,
ServiceAvailabilityRequirement,
ServiceDefinition,
ServiceLaunchRequest,
ServiceLaunchResult,
TemporalRevision,
derive_service_restriction,
resolve_mandate_candidates,
revise_procedure_party,
revise_formal_decision,
revise_mandate_definition,
revoke_party_representation,
)
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
def _reference(
kind: str,
object_id: str,
*,
tenant_id: str = "tenant-1",
) -> InstitutionalReference:
owners = {
"institution": "tenancy",
"organization_unit": "organizations",
"function": "organizations",
"function_assignment": "idm",
"mandate": "cases",
"jurisdiction": "cases",
"service": "portal",
"form": "forms",
"form_submission": "forms_runtime",
"case": "cases",
"party": "cases",
"decision": "committee",
"work_item": "workflow_engine",
"workflow": "workflow_engine",
"approval": "workflow_engine",
"record": "audit",
}
return InstitutionalReference(
kind=kind,
owner_module=owners[kind],
object_id=object_id,
tenant_id=tenant_id,
version="3",
valid_at=NOW,
label=f"Protected label for {object_id}",
)
def _context() -> GovernedContextEnvelope:
external = ExternalObjectReference(
system="gis.example",
object_type="administrative_area",
object_id="area-7",
maturity="read",
authority_mode="external_mirror",
canonical_url="https://gis.example/areas/7",
observed_at=NOW,
)
return GovernedContextEnvelope(
tenant_id="tenant-1",
temporal=TemporalRevision(
revision="7",
valid_from=NOW - timedelta(days=1),
valid_to=NOW + timedelta(days=1),
recorded_at=NOW,
change_reason="Case state changed.",
),
actor=ActorRepresentationReference(
tenant_id="tenant-1",
account_id="account-1",
identity_id="identity-1",
represented_function_ref=_reference("function", "function-1"),
function_assignment_ref=_reference(
"function_assignment", "assignment-1"
),
mandate_ref=_reference("mandate", "mandate-1"),
),
institution_ref=_reference("institution", "institution-1"),
organization_unit_ref=_reference("organization_unit", "unit-1"),
function_ref=_reference("function", "function-1"),
mandate_ref=_reference("mandate", "mandate-1"),
jurisdiction_refs=(_reference("jurisdiction", "jurisdiction-1"),),
service_ref=_reference("service", "service-1"),
case_ref=_reference("case", "case-1"),
party_refs=(_reference("party", "party-1"),),
work_item_ref=_reference("work_item", "work-item-1"),
workflow_ref=_reference("workflow", "workflow-1"),
approval_refs=(_reference("approval", "approval-1"),),
decision_ref=_reference("decision", "decision-1"),
record_refs=(_reference("record", "record-1"),),
legal_bases=(
LegalBasisReference(
kind="law",
authority="Example legislature",
reference="law-1/section-3",
version="2026-01",
effective_from=NOW - timedelta(days=30),
title="Protected legal title",
inspection_url="https://law.example/section-3",
),
),
evidence=(
EvidenceReference(
kind="document",
owner_module="files",
evidence_id="file-1",
tenant_id="tenant-1",
version="4",
checksum="sha256:abcd",
captured_at=NOW,
inspection_url="https://govoplan.example/files/file-1",
),
),
information_governance=InformationGovernanceReference(
classification="confidential",
purposes=("case decision",),
legal_basis_refs=("law-1/section-3",),
retention_policy_ref="records:case-default",
disclosure_state="restricted",
),
external_sources=(
ExternalSourceReference(
reference=external,
authority_mode="external_mirror",
maturity="read",
freshness_state="current",
health_state="ok",
),
),
)
class InstitutionalContractTests(unittest.TestCase):
def test_context_round_trip_preserves_versioned_references(self) -> None:
context = _context()
restored = GovernedContextEnvelope.from_mapping(
context.to_dict(include_inspection=True)
)
self.assertEqual(context.tenant_id, restored.tenant_id)
self.assertEqual("mandate-1", restored.mandate_ref.object_id)
self.assertEqual(
"external_mirror", restored.external_sources[0].authority_mode
)
self.assertEqual("workflow-1", restored.workflow_ref.object_id)
self.assertEqual("approval-1", restored.approval_refs[0].object_id)
self.assertEqual("record-1", restored.record_refs[0].object_id)
self.assertTrue(restored.temporal.effective_at(NOW))
def test_cross_tenant_reference_is_rejected(self) -> None:
with self.assertRaisesRegex(InstitutionalContextError, "different tenant"):
GovernedContextEnvelope(
tenant_id="tenant-1",
temporal=TemporalRevision(revision="1", recorded_at=NOW),
case_ref=_reference("case", "case-2", tenant_id="tenant-2"),
)
def test_default_serialization_redacts_labels_and_inspection_links(self) -> None:
payload = _context().to_dict()
self.assertIsNone(payload["case_ref"]["label"])
self.assertIsNone(payload["legal_bases"][0]["title"])
self.assertIsNone(payload["legal_bases"][0]["inspection_url"])
self.assertIsNone(payload["evidence"][0]["inspection_url"])
def test_event_and_action_envelopes_carry_reference_context(self) -> None:
context = _context()
event = PlatformEvent(
type="committee.decision.recorded",
module_id="committee",
institutional_context=context,
)
action = ActionExecutionRequest(
tenant_id="tenant-1",
action_key="committee.decision.publish",
input={"decision_ref": "decision-1"},
idempotency_key="decision-1:publish:1",
invocation=AutomationInvocation(kind="workflow"),
institutional_context=context,
)
self.assertEqual(
"decision-1",
event.to_dict()["institutional_context"]["decision_ref"]["object_id"],
)
self.assertIs(context, action.institutional_context)
def test_formal_decision_redacts_reasoning_but_keeps_evidence_refs(self) -> None:
context = _context()
decision = FormalDecision(
reference=_reference("decision", "decision-1"),
temporal=context.temporal,
decision_type="permit",
subject_refs=(_reference("case", "case-1"),),
state="decided",
authority_context=context,
fact_evidence=context.evidence,
legal_bases=context.legal_bases,
operative_result="Permit granted.",
reasoning="Protected reasoning.",
)
redacted = decision.to_dict()
protected = decision.to_dict(include_protected=True)
self.assertIsNone(redacted["reasoning"])
self.assertEqual("Protected reasoning.", protected["reasoning"])
self.assertEqual(
"file-1", redacted["fact_evidence"][0]["evidence_id"]
)
restored = FormalDecision.from_mapping(protected)
self.assertEqual("Protected reasoning.", restored.reasoning)
self.assertEqual("decision-1", restored.reference.object_id)
def test_semantic_provider_dtos_round_trip_without_domain_tables(self) -> None:
context = _context()
mandate = MandateDefinition(
reference=_reference("mandate", "mandate-1"),
temporal=context.temporal,
task_types=("committee.formal_decision",),
authority_types=("permit",),
organization_unit_refs=(
_reference("organization_unit", "unit-1"),
),
function_refs=(_reference("function", "function-1"),),
jurisdiction_refs=context.jurisdiction_refs,
legal_bases=context.legal_bases,
evidence=context.evidence,
)
resolution = MandateResolution(
competent=True,
mandates=(mandate,),
explanation="Authority matched.",
evidence=context.evidence,
)
service = ServiceDefinition(
reference=_reference("service", "service-1"),
key="permit.service",
temporal=context.temporal,
title="Permit service",
audience=("residents",),
legal_bases=context.legal_bases,
channels=("portal", "mail"),
responsible_organization_ref=_reference(
"organization_unit", "unit-1"
),
responsible_function_ref=_reference("function", "function-1"),
mandate_ref=_reference("mandate", "mandate-1"),
jurisdiction_refs=context.jurisdiction_refs,
bindings=(ServiceBinding("workflow", "workflow:permit"),),
publication_state="published",
)
represented = _reference("party", "party-applicant")
representative = _reference("party", "party-agent")
party = ProcedureParty(
reference=represented,
procedure_ref=_reference("case", "case-1"),
role="applicant",
subject=PartySubjectReference(
kind="identity",
provider="identity",
subject_id="identity-1",
tenant_id="tenant-1",
version="2",
),
temporal=context.temporal,
preferred_channels=("portal",),
permitted_channels=("portal", "mail"),
delivery_recipient=True,
representations=(
PartyRepresentation(
representative_party_ref=representative,
represented_party_ref=represented,
power_ref="power-1",
permitted_actions=("submit", "receive"),
temporal=context.temporal,
evidence=context.evidence,
),
),
contact_snapshot_refs=("addresses:snapshot-1",),
evidence=context.evidence,
)
restored_resolution = MandateResolution.from_mapping(
resolution.to_dict(include_inspection=True)
)
restored_service = ServiceDefinition.from_mapping(service.to_dict())
restored_party = ProcedureParty.from_mapping(
party.to_dict(include_inspection=True)
)
self.assertTrue(restored_resolution.competent)
self.assertEqual(
"committee.formal_decision",
restored_resolution.mandates[0].task_types[0],
)
self.assertEqual("workflow:permit", restored_service.bindings[0].reference)
self.assertEqual("identity-1", restored_party.subject.subject_id)
self.assertEqual(
"party-agent",
restored_party.representations[0].representative_party_ref.object_id,
)
def test_invalid_semantic_states_are_rejected(self) -> None:
with self.assertRaisesRegex(InstitutionalContextError, "disclosure state"):
InformationGovernanceReference(
classification="internal",
purposes=("test",),
disclosure_state="invalid", # type: ignore[arg-type]
)
def test_service_launch_contract_requires_exact_same_tenant_targets(self) -> None:
service_ref = _reference("service", "service-1")
binding = ServiceBinding("case", "permit")
request = ServiceLaunchRequest(
service_ref=service_ref,
binding=binding,
idempotency_key="launch-1",
requested_at=NOW,
parameters={"title": "Permit application"},
)
result = ServiceLaunchResult(
service_ref=service_ref,
binding=binding,
state="started",
target_ref=_reference("case", "case-1"),
href="/cases/case-1",
metadata={"case_number": "PERMIT-1"},
)
self.assertEqual("launch-1", request.idempotency_key)
self.assertEqual("case-1", result.to_dict()["target_ref"]["object_id"])
with self.assertRaisesRegex(InstitutionalContextError, "another tenant"):
ServiceLaunchResult(
service_ref=service_ref,
binding=binding,
state="started",
target_ref=_reference("case", "case-2", tenant_id="tenant-2"),
metadata={},
)
def test_form_definition_contract_round_trips_exact_schema(self) -> None:
definition = FormDefinition(
reference=_reference("form", "permit-application"),
key="permit-application",
temporal=TemporalRevision(
revision="3",
recorded_at=NOW,
change_reason="Add delivery preference.",
),
title="Permit application",
description="Apply for a permit.",
fields=(
FormFieldDefinition(
key="email",
label="Email address",
value_type="email",
required=True,
constraints={"max_length": 255},
),
FormFieldDefinition(
key="delivery",
label="Delivery",
value_type="choice",
options=("portal", "mail"),
),
),
max_attachments=3,
signature_requirement="optional",
policy_refs=("policy:permit-intake",),
handoff_kinds=("case",),
)
restored = FormDefinition.from_mapping(definition.to_dict())
self.assertEqual("3", restored.reference.version)
self.assertEqual("email", restored.fields[0].key)
self.assertEqual(("portal", "mail"), restored.fields[1].options)
with self.assertRaisesRegex(InstitutionalContextError, "unique"):
replace(restored, fields=(restored.fields[0], restored.fields[0]))
def test_service_launch_redirect_rejects_credentialed_urls(self) -> None:
with self.assertRaisesRegex(InstitutionalContextError, "credential-free"):
ServiceLaunchResult(
service_ref=_reference("service", "service-1"),
binding=ServiceBinding("external", "external-entry"),
state="redirect",
href="https://user:secret@example.test/start",
metadata={},
)
with self.assertRaisesRegex(InstitutionalContextError, "must be a boolean"):
ServiceBinding.from_mapping(
{
"kind": "workflow",
"reference": "workflow:permit",
"required": "false",
}
)
def test_service_derivation_can_only_tighten_parent_constraints(self) -> None:
parent = ServiceDefinition(
reference=_reference("service", "permit-template"),
key="permit.apply",
temporal=TemporalRevision(
revision="7",
valid_from=NOW - timedelta(days=1),
valid_to=NOW + timedelta(days=10),
recorded_at=NOW - timedelta(days=2),
),
title="Permit service",
audience=("resident", "business"),
prerequisites=("registered-address",),
required_evidence_types=("application",),
channels=("portal", "mail"),
bindings=(ServiceBinding("case", "permit-case"),),
availability_requirements=(
ServiceAvailabilityRequirement("policy", "permit-access"),
),
publication_state="published",
)
temporal = TemporalRevision(
revision="8",
valid_from=NOW,
valid_to=NOW + timedelta(days=5),
recorded_at=NOW,
change_reason="Tenant specialization.",
)
reference = replace(
_reference("service", "permit-tenant"),
version="8",
)
derived = derive_service_restriction(
parent,
reference=reference,
temporal=temporal,
audience=("resident",),
channels=("portal",),
add_prerequisites=("tenant-residency",),
add_required_evidence_types=("identity",),
add_bindings=(ServiceBinding("workflow", "permit-review"),),
)
self.assertEqual(parent.reference, derived.derived_from_ref)
self.assertEqual(("resident",), derived.audience)
self.assertEqual(("portal",), derived.channels)
self.assertEqual(
("registered-address", "tenant-residency"),
derived.prerequisites,
)
self.assertIn(ServiceBinding("case", "permit-case"), derived.bindings)
self.assertEqual(
parent.availability_requirements,
derived.availability_requirements,
)
self.assertEqual(
parent.availability_requirements,
ServiceDefinition.from_mapping(
derived.to_dict()
).availability_requirements,
)
with self.assertRaisesRegex(InstitutionalContextError, "widen.*audience"):
derive_service_restriction(
parent,
reference=reference,
temporal=temporal,
audience=("resident", "visitor"),
)
with self.assertRaisesRegex(InstitutionalContextError, "beyond"):
derive_service_restriction(
parent,
reference=reference,
temporal=replace(temporal, valid_to=None),
)
with self.assertRaisesRegex(
InstitutionalContextError,
"publication state",
):
derive_service_restriction(
replace(parent, publication_state="suspended"),
reference=reference,
temporal=temporal,
publication_state="published",
)
def test_mandate_resolution_is_effective_scoped_and_deterministic(self) -> None:
context = _context()
mandate = MandateDefinition(
reference=_reference("mandate", "mandate-1"),
temporal=TemporalRevision(
revision="1",
valid_from=NOW - timedelta(days=1),
valid_to=NOW + timedelta(days=1),
recorded_at=NOW - timedelta(days=2),
),
task_types=("committee.formal_decision",),
authority_types=("permit",),
organization_unit_refs=(
_reference("organization_unit", "unit-1"),
),
function_refs=(_reference("function", "function-1"),),
jurisdiction_refs=context.jurisdiction_refs,
subject_types=("permit",),
evidence=context.evidence,
)
request = MandateResolutionRequest(
tenant_id="tenant-1",
effective_at=NOW,
task_type="committee.formal_decision",
authority_type="permit",
organization_unit_ref=replace(
_reference("organization_unit", "unit-1"), version="9"
),
function_ref=replace(
_reference("function", "function-1"), version="9"
),
jurisdiction_refs=tuple(
replace(item, version="9") for item in context.jurisdiction_refs
),
subject_type="permit",
)
resolution = resolve_mandate_candidates(
request,
(
replace(
mandate,
reference=_reference("mandate", "retired"),
status="retired",
),
mandate,
),
)
self.assertTrue(resolution.competent)
self.assertEqual("mandate-1", resolution.mandates[0].reference.object_id)
self.assertEqual("file-1", resolution.evidence[0].evidence_id)
ambiguous = resolve_mandate_candidates(
request,
(
mandate,
replace(
mandate,
reference=_reference("mandate", "mandate-2"),
),
),
)
self.assertFalse(ambiguous.competent)
self.assertEqual(2, len(ambiguous.conflict_refs))
missing_jurisdiction = resolve_mandate_candidates(
replace(request, jurisdiction_refs=()),
(mandate,),
)
self.assertFalse(missing_jurisdiction.competent)
def test_mandate_resolution_rejects_cross_tenant_candidates(self) -> None:
request = MandateResolutionRequest(
tenant_id="tenant-1",
effective_at=NOW,
task_type="committee.formal_decision",
authority_type="permit",
)
candidate = MandateDefinition(
reference=_reference("mandate", "mandate-2", tenant_id="tenant-2"),
temporal=TemporalRevision(revision="1", recorded_at=NOW),
task_types=("committee.formal_decision",),
authority_types=("permit",),
)
with self.assertRaisesRegex(InstitutionalContextError, "cross tenants"):
resolve_mandate_candidates(request, (candidate,))
def test_mandate_lifecycle_revisions_are_linked_by_stable_identity(self) -> None:
current = MandateDefinition(
reference=_reference("mandate", "mandate-1"),
temporal=TemporalRevision(revision="1", recorded_at=NOW),
task_types=("committee.formal_decision",),
authority_types=("permit",),
status="active",
)
temporal = TemporalRevision(
revision="2",
recorded_at=NOW + timedelta(minutes=1),
change_reason="Authority temporarily suspended.",
)
suspended = revise_mandate_definition(
current,
expected_revision="1",
temporal=temporal,
status="suspended",
suspension_reason="Delegation is under review.",
)
self.assertEqual("active", current.status)
self.assertEqual("suspended", suspended.status)
self.assertEqual("2", suspended.reference.version)
self.assertEqual(current.reference.object_id, suspended.reference.object_id)
with self.assertRaisesRegex(InstitutionalContextError, "stale"):
revise_mandate_definition(
current,
expected_revision="0",
temporal=temporal,
status="suspended",
suspension_reason="Review.",
)
with self.assertRaisesRegex(InstitutionalContextError, "not allowed"):
revise_mandate_definition(
suspended,
expected_revision="2",
temporal=replace(
temporal,
revision="3",
change_reason="Invalid reset.",
),
status="draft",
)
def test_formal_decision_revisions_are_linked_and_occ_guarded(self) -> None:
context = _context()
reference = replace(
_reference("decision", "decision-1"),
version=context.temporal.revision,
)
context = replace(context, decision_ref=reference)
current = FormalDecision(
reference=reference,
temporal=context.temporal,
decision_type="permit",
subject_refs=(_reference("case", "case-1"),),
state="decided",
authority_context=context,
fact_evidence=context.evidence,
legal_bases=context.legal_bases,
operative_result="Permit granted.",
reasoning="Protected reasoning.",
requested_effects=(
DecisionEffectReference("notify", "requested"),
),
)
with self.assertRaisesRegex(
InstitutionalContextError,
"service account",
):
replace(current, assurance_level="automated_under_mandate")
revision = TemporalRevision(
revision="8",
valid_from=NOW + timedelta(minutes=1),
recorded_at=NOW + timedelta(minutes=1),
change_reason="Decision entered into force.",
)
effective = revise_formal_decision(
current,
expected_revision="7",
temporal=revision,
state="effective",
)
self.assertEqual("decided", current.state)
self.assertEqual("8", effective.reference.version)
self.assertEqual("7", effective.supersedes_ref.version)
self.assertEqual(
effective.reference,
effective.authority_context.decision_ref,
)
self.assertEqual(current.requested_effects, effective.requested_effects)
with self.assertRaisesRegex(InstitutionalContextError, "stale"):
revise_formal_decision(
current,
expected_revision="6",
temporal=revision,
state="effective",
)
with self.assertRaisesRegex(InstitutionalContextError, "not allowed"):
revise_formal_decision(
effective,
expected_revision="8",
temporal=replace(
revision,
revision="9",
change_reason="Invalid regression.",
),
state="proposed",
)
corrected = revise_formal_decision(
effective,
expected_revision="8",
temporal=replace(
revision,
revision="9",
recorded_at=NOW + timedelta(minutes=2),
change_reason="Corrected operative wording.",
),
state="corrected",
operative_result="Permit granted subject to condition A.",
)
self.assertEqual(effective.reference, corrected.correction_of_ref)
self.assertEqual(effective.reference, corrected.supersedes_ref)
def test_party_corrections_and_representation_revocation_are_versioned(self) -> None:
context = _context()
represented = _reference("party", "party-applicant")
representative = _reference("party", "party-agent")
representation = PartyRepresentation(
representative_party_ref=representative,
represented_party_ref=represented,
power_ref="power-1",
permitted_actions=("submit", "receive"),
temporal=TemporalRevision(
revision="1",
valid_from=NOW - timedelta(days=1),
recorded_at=NOW - timedelta(days=1),
),
evidence=context.evidence,
)
current = ProcedureParty(
reference=represented,
procedure_ref=_reference("case", "case-1"),
role="applicant",
subject=PartySubjectReference(
kind="identity",
provider="identity",
subject_id="identity-1",
tenant_id="tenant-1",
version="2",
),
temporal=TemporalRevision(revision="1", recorded_at=NOW),
permitted_channels=("mail", "postbox"),
preferred_channels=("mail",),
delivery_recipient=True,
representations=(representation,),
contact_snapshot_refs=("addresses:snapshot-1",),
evidence=context.evidence,
)
revision = TemporalRevision(
revision="2",
valid_from=NOW,
recorded_at=NOW + timedelta(minutes=1),
change_reason="Correct preferred delivery channel.",
)
corrected = revise_procedure_party(
current,
expected_revision="1",
temporal=revision,
preferred_channels=("postbox",),
)
revoked_power = revoke_party_representation(
representation,
expected_revision="1",
temporal=TemporalRevision(
revision="2",
valid_from=NOW - timedelta(days=1),
valid_to=NOW + timedelta(minutes=2),
recorded_at=NOW + timedelta(minutes=2),
change_reason="Power was revoked.",
),
revoked_at=NOW + timedelta(minutes=2),
)
self.assertEqual(("mail",), current.preferred_channels)
self.assertEqual(("postbox",), corrected.preferred_channels)
self.assertEqual("2", corrected.reference.version)
self.assertIsNone(representation.revoked_at)
self.assertEqual(NOW + timedelta(minutes=2), revoked_power.revoked_at)
with self.assertRaisesRegex(InstitutionalContextError, "stale"):
revise_procedure_party(
current,
expected_revision="0",
temporal=revision,
)
if __name__ == "__main__":
unittest.main()
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
import unittest
from unittest.mock import MagicMock, patch
from govoplan_core.db.migration_lock import (
deployment_migration_lock,
migration_lock_key,
)
class MigrationLockTests(unittest.TestCase):
def test_lock_key_is_stable_and_namespaced(self) -> None:
first = migration_lock_key("installation-a", "release")
self.assertEqual(first, migration_lock_key("installation-a", "release"))
self.assertNotEqual(first, migration_lock_key("installation-b", "release"))
self.assertNotEqual(first, migration_lock_key("installation-a", "dev"))
self.assertGreaterEqual(first, -(2**63))
self.assertLess(first, 2**63)
def test_non_postgres_migration_lock_is_a_noop(self) -> None:
entered = False
with deployment_migration_lock(
"sqlite+pysqlite:///:memory:",
installation_id="installation-a",
migration_track="release",
):
entered = True
self.assertTrue(entered)
def test_postgres_lock_is_held_for_the_guarded_operation(self) -> None:
result = MagicMock()
result.scalar_one.return_value = True
connection = MagicMock()
connection.execute.return_value = result
engine = MagicMock()
engine.connect.return_value = connection
with patch(
"govoplan_core.db.migration_lock.create_engine",
return_value=engine,
):
with deployment_migration_lock(
"postgresql://user:secret@db.example.test/govoplan",
installation_id="installation-a",
migration_track="release",
):
self.assertEqual(1, connection.execute.call_count)
self.assertEqual(2, connection.execute.call_count)
connection.close.assert_called_once_with()
engine.dispose.assert_called_once_with()
if __name__ == "__main__":
unittest.main()
+35
View File
@@ -1261,6 +1261,41 @@ finally:
self.assertIn("module_active", {issue.code for issue in preflight.issues})
self.assertIn("module_desired", {issue.code for issue in preflight.issues})
def test_module_installer_blocks_node_local_package_mutation_in_shared_profile(
self,
) -> None:
plan = ModuleInstallPlan(
items=(
ModuleInstallPlanItem(
module_id="files",
action="update",
source="manual",
python_package="govoplan-files",
python_ref="govoplan-files==1.2.3",
),
)
)
with patch.dict(os.environ, {"GOVOPLAN_STATE_PROFILE": "shared"}):
preflight = module_install_preflight(
plan=plan,
available={
"files": ModuleManifest(
id="files",
name="Files",
version="1.2.2",
)
},
current_enabled=("files",),
desired_enabled=("files",),
maintenance_mode=True,
)
self.assertFalse(preflight.allowed)
self.assertIn(
"immutable_cluster_release_required",
{issue.code for issue in preflight.issues},
)
def test_module_installer_preflight_blocks_incompatible_manifest(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-compat-", dir=_TEST_ROOT))
settings = _settings(root)
+169
View File
@@ -0,0 +1,169 @@
from __future__ import annotations
from io import BytesIO
from pathlib import Path
import tempfile
import unittest
from govoplan_core.core.object_storage import (
LocalFilesystemStorageBackend,
S3StorageBackend,
StorageBackendError,
StorageObjectMissing,
normalize_storage_key,
)
class _S3Error(RuntimeError):
def __init__(self, code: str, status: int) -> None:
super().__init__(code)
self.response = {
"Error": {"Code": code},
"ResponseMetadata": {"HTTPStatusCode": status},
}
class _FakeS3Client:
def __init__(self) -> None:
self.objects: dict[str, bytes] = {}
self.head_error: Exception | None = None
def put_object(self, *, Key: str, Body: bytes, **_kwargs) -> None:
self.objects[Key] = Body
def get_object(self, *, Key: str, **_kwargs):
try:
payload = self.objects[Key]
except KeyError as exc:
raise _S3Error("NoSuchKey", 404) from exc
return {"Body": BytesIO(payload), "ContentLength": len(payload)}
def head_object(self, *, Key: str, **_kwargs):
if self.head_error is not None:
raise self.head_error
try:
payload = self.objects[Key]
except KeyError as exc:
raise _S3Error("NotFound", 404) from exc
return {"ContentLength": len(payload)}
def delete_object(self, *, Key: str, **_kwargs) -> None:
self.objects.pop(Key, None)
class ObjectStorageTests(unittest.TestCase):
def test_local_backend_round_trip_pagination_and_safe_keys(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-object-store-") as directory:
backend = LocalFilesystemStorageBackend(Path(directory))
backend.put_bytes("campaign/a.eml", b"a")
backend.put_bytes("campaign/b.eml", b"bb")
first = backend.list_objects(prefix="campaign/", limit=1)
second = backend.list_objects(
prefix="campaign/",
after=first.next_cursor,
limit=1,
)
self.assertEqual(b"a", backend.get_bytes("campaign/a.eml"))
self.assertEqual(1, backend.stat("campaign/a.eml").size_bytes)
self.assertEqual(
("campaign/a.eml",), tuple(item.key for item in first.objects)
)
self.assertEqual("campaign/a.eml", first.next_cursor)
self.assertEqual(
("campaign/b.eml",), tuple(item.key for item in second.objects)
)
self.assertIsNone(second.next_cursor)
with self.assertRaises(StorageBackendError):
normalize_storage_key("../outside")
with self.assertRaises(StorageObjectMissing):
backend.get_bytes("campaign/missing.eml")
def test_local_listing_is_globally_sorted_and_ignores_symlinks(self) -> None:
with (
tempfile.TemporaryDirectory(prefix="govoplan-object-store-") as directory,
tempfile.TemporaryDirectory(prefix="govoplan-object-outside-") as outside,
):
root = Path(directory)
backend = LocalFilesystemStorageBackend(root)
backend.put_bytes("a/x.txt", b"nested")
backend.put_bytes("a.txt", b"sibling")
backend.put_bytes("b.txt", b"last")
outside_file = Path(outside) / "secret.txt"
outside_file.write_bytes(b"outside")
(root / "linked-file.txt").symlink_to(outside_file)
(root / "linked-directory").symlink_to(
Path(outside), target_is_directory=True
)
first = backend.list_objects(prefix="", limit=2)
second = backend.list_objects(
prefix="",
after=first.next_cursor,
limit=2,
)
self.assertEqual(
("a.txt", "a/x.txt"),
tuple(item.key for item in first.objects),
)
self.assertEqual("a/x.txt", first.next_cursor)
self.assertEqual(
("b.txt",),
tuple(item.key for item in second.objects),
)
self.assertIsNone(second.next_cursor)
with self.assertRaises(StorageBackendError):
backend.exists("../outside")
def test_s3_backend_distinguishes_missing_objects_from_backend_failure(
self,
) -> None:
backend = S3StorageBackend(
bucket="files",
endpoint_url="http://garage:3900",
region_name="garage",
access_key_id="key",
secret_access_key="secret",
deployment_managed=True,
)
client = _FakeS3Client()
backend._client = client
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.assertFalse(backend.exists("campaign/missing.eml"))
client.head_error = _S3Error("AccessDenied", 403)
with self.assertRaises(StorageBackendError):
backend.exists("campaign/message.eml")
def test_external_s3_requires_explicit_https_deployment_trust(self) -> None:
trusted = S3StorageBackend(
bucket="files",
endpoint_url="https://s3.internal.example.test",
region_name="eu-test-1",
access_key_id="key",
secret_access_key="secret",
endpoint_trusted=True,
)
trusted._client = _FakeS3Client()
trusted.put_bytes("files/document.txt", b"document")
self.assertEqual(b"document", trusted.get_bytes("files/document.txt"))
rejected = S3StorageBackend(
bucket="files",
endpoint_url="http://s3.internal.example.test",
region_name="eu-test-1",
access_key_id="key",
secret_access_key="secret",
endpoint_trusted=True,
)
with self.assertRaisesRegex(StorageBackendError, "HTTPS origin"):
rejected.client
if __name__ == "__main__":
unittest.main()
+536
View File
@@ -0,0 +1,536 @@
from __future__ import annotations
from datetime import UTC, datetime
import unittest
from govoplan_core.core.modules import ModuleManifest
from govoplan_core.core.provider_governance import (
ArchitectureDeclarationError,
ExternalProviderDeclaration,
ExternalProviderRuntimeState,
ExternalProviderStateContext,
ExternalProviderStateProviderRegistration,
ModuleArchitectureDeclaration,
ModuleArchitectureDocumentation,
ModuleMaturityEvidence,
ProviderBehaviorDeclaration,
ProviderObjectDeclaration,
collect_external_provider_states,
declared_module_architecture,
external_provider_from_mapping,
module_architecture_from_mapping,
module_architecture_issues,
)
from govoplan_core.core.registry import PlatformRegistry, RegistryError
def _architecture(
*,
providers: tuple[str, ...] = (),
modes: tuple[str, ...] = ("external_mirror",),
) -> ModuleArchitectureDeclaration:
return ModuleArchitectureDeclaration(
layer="data_reporting_integration",
kind="integration",
maturity="vertical_slice",
evidence=(
ModuleMaturityEvidence(
kind="test",
reference="tests/test_provider.py",
summary="Exercises the bounded provider contract.",
),
ModuleMaturityEvidence(
kind="documentation",
reference="docs/PROVIDER.md",
summary="Documents authority and outage behavior.",
),
),
known_limits=("Only bounded test objects are supported.",),
supported_authority_modes=modes,
owned_concepts=("external transport",),
non_owned_concepts=("domain records",),
target_tested_providers=providers,
)
def _mirror_provider() -> ExternalProviderDeclaration:
return ExternalProviderDeclaration(
id="connectors.read_mirror",
module_id="connectors",
label="Read-only mirror",
maturity="read",
operations=("discover", "search", "read", "preview"),
objects=(
ProviderObjectDeclaration(
object_type="register_entry",
field_groups=("identity", "content", "source_metadata"),
authority_modes=("external_authoritative", "external_mirror"),
default_authority_mode="external_mirror",
),
),
behavior=ProviderBehaviorDeclaration(
revision_tokens="ETag and source revision are retained.",
concurrency="Reads can require an expected source revision.",
freshness="The latest successful acquisition time is reported.",
health="Transport and parsing health are reported separately.",
max_read_items=500,
evidence="Immutable snapshots retain acquisition provenance.",
correction="Refresh creates a new snapshot; old evidence is retained.",
reconciliation="Compare the latest source revision and content digest.",
outage="The last governed snapshot remains readable as stale.",
classifications=("internal",),
purposes=("governed import",),
retention="Owning datasource retention policy applies.",
secret_handling="Credentials stay in credential envelopes.",
),
capability_names=("connectors.readMirror",),
)
def _writable_provider() -> ExternalProviderDeclaration:
return ExternalProviderDeclaration(
id="calendar.caldav_sync",
module_id="calendar",
label="CalDAV synchronization",
maturity="synchronize",
operations=("discover", "read", "write", "delete", "synchronize", "preview"),
objects=(
ProviderObjectDeclaration(
object_type="calendar_event",
field_groups=("identity", "schedule", "participants", "recurrence"),
authority_modes=("external_authoritative", "governed_sync"),
default_authority_mode="governed_sync",
),
),
behavior=ProviderBehaviorDeclaration(
revision_tokens="ETag and sync token.",
concurrency="Conditional requests reject stale ETags.",
freshness="Last successful sync and pending changes are reported.",
health="Discovery, authentication, and collection health are separate.",
max_read_items=1000,
idempotency="Stable UID and operation keys suppress duplicate effects.",
retry="Bounded retry is limited to classified transient failures.",
timeout_seconds=30,
conflicts="Conflicts remain explicit until policy or a user resolves them.",
outcome_unknown="Timed-out writes are outcome-unknown, never blindly retried.",
outcome_unknown_supported=True,
evidence="Request, response token, and reconciliation evidence are retained.",
audit_event_types=("calendar.sync.requested", "calendar.sync.reconciled"),
correction="A later governed write or tombstone corrects remote state.",
rollback="Remote writes are not assumed rollback-safe.",
compensation="A compensating update can be requested after reconciliation.",
reconciliation="Read by UID and compare ETag before retrying.",
outage="Local state remains marked stale and pending effects stay queued.",
classifications=("confidential",),
purposes=("calendar synchronization",),
retention="Calendar and audit retention policies apply independently.",
secret_handling="Credentials are referenced through credential envelopes.",
),
capability_names=("calendar.sync",),
)
def _state_registration(
*,
module_id: str,
provider_id: str,
) -> ExternalProviderStateProviderRegistration:
return ExternalProviderStateProviderRegistration(
module_id=module_id,
provider_id=provider_id,
provider=lambda _context: (),
)
class ProviderGovernanceContractTests(unittest.TestCase):
def test_concise_declaration_keeps_repository_evidence_explicit(self) -> None:
architecture = declared_module_architecture(
layer="human_work_procedure",
kind="domain",
maturity="vertical_slice",
documentation_ref="docs/DOMAIN.md",
test_ref="tests/test_service.py",
known_limits=("Only the bounded service slice is implemented.",),
owned_concepts=("domain record",),
non_owned_concepts=("identity",),
)
self.assertEqual("vertical_slice", architecture.maturity)
self.assertEqual(
{"documentation", "test"},
{item.kind for item in architecture.evidence},
)
self.assertEqual(("domain record",), architecture.owned_concepts)
def test_declarations_round_trip_through_catalog_mappings(self) -> None:
provider = _writable_provider()
architecture = _architecture(
providers=(provider.id,),
modes=("external_authoritative", "governed_sync"),
)
self.assertEqual(
architecture,
module_architecture_from_mapping(architecture.to_dict()),
)
self.assertEqual(
provider,
external_provider_from_mapping(provider.to_dict()),
)
def test_read_only_mirror_declaration_is_registry_validated(self) -> None:
provider = _mirror_provider()
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="connectors",
name="Connectors",
version="1.0.0",
architecture=_architecture(
providers=(provider.id,),
modes=("external_authoritative", "external_mirror"),
),
external_providers=(provider,),
external_provider_state_providers=(
_state_registration(
module_id="connectors",
provider_id=provider.id,
),
),
capability_factories={"connectors.readMirror": lambda _context: object()},
)
)
snapshot = registry.validate()
self.assertEqual("connectors", snapshot.manifests[0].id)
self.assertEqual((provider,), registry.external_provider_declarations())
def test_writable_outcome_unknown_provider_is_explicit(self) -> None:
provider = _writable_provider()
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="calendar",
name="Calendar",
version="1.0.0",
architecture=_architecture(
providers=(provider.id,),
modes=("external_authoritative", "governed_sync"),
),
external_providers=(provider,),
external_provider_state_providers=(
_state_registration(
module_id="calendar",
provider_id=provider.id,
),
),
capability_factories={"calendar.sync": lambda _context: object()},
)
)
registry.validate()
payload = provider.to_dict()
self.assertTrue(payload["behavior"]["outcome_unknown_supported"])
self.assertIn("reconciliation", payload["behavior"])
def test_provider_requires_a_sanitized_runtime_state_registration(self) -> None:
provider = _mirror_provider()
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="connectors",
name="Connectors",
version="1.0.0",
architecture=_architecture(
providers=(provider.id,),
modes=("external_authoritative", "external_mirror"),
),
external_providers=(provider,),
capability_factories={
"connectors.readMirror": lambda _context: object()
},
)
)
with self.assertRaisesRegex(
RegistryError,
"require sanitized runtime state providers",
):
registry.validate()
def test_runtime_binding_states_are_validated_and_aggregated(self) -> None:
observed_at = datetime(2026, 8, 1, 12, 0, tzinfo=UTC)
def states(_context: ExternalProviderStateContext):
return (
ExternalProviderRuntimeState(
provider_id="calendar.caldav_sync",
binding_ref="calendar:sync-source:one",
authority_mode="governed_sync",
observed_at=observed_at,
configured=True,
active=True,
health="healthy",
freshness="current",
conflict="clear",
recovery="ready",
),
ExternalProviderRuntimeState(
provider_id="calendar.caldav_sync",
binding_ref="calendar:sync-source:two",
authority_mode="external_mirror",
observed_at=observed_at,
configured=True,
active=True,
health="warning",
freshness="stale",
conflict="pending",
recovery="attention",
),
)
payload = collect_external_provider_states(
(
ExternalProviderStateProviderRegistration(
module_id="calendar",
provider_id="calendar.caldav_sync",
provider=states,
),
),
ExternalProviderStateContext(session=None, tenant_id="tenant-1"),
)["calendar.caldav_sync"]
self.assertEqual("warning", payload["health"])
self.assertEqual("stale", payload["freshness"])
self.assertEqual("pending", payload["conflict"])
self.assertEqual("attention", payload["recovery"])
self.assertEqual(2, len(payload["bindings"]))
def test_runtime_state_registration_requires_declared_provider(self) -> None:
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="calendar",
name="Calendar",
version="1.0.0",
architecture=_architecture(),
external_provider_state_providers=(
ExternalProviderStateProviderRegistration(
module_id="calendar",
provider_id="calendar.caldav_sync",
provider=lambda _context: (),
),
),
)
)
with self.assertRaisesRegex(RegistryError, "undeclared provider"):
registry.validate()
def test_runtime_state_failure_is_sanitized(self) -> None:
def fail(_context: ExternalProviderStateContext):
raise RuntimeError("secret transport detail")
payload = collect_external_provider_states(
(
ExternalProviderStateProviderRegistration(
module_id="calendar",
provider_id="calendar.caldav_sync",
provider=fail,
),
),
ExternalProviderStateContext(session=None),
)["calendar.caldav_sync"]
self.assertEqual("error", payload["health"])
self.assertNotIn("secret transport detail", str(payload))
def test_supported_claim_requires_evidence_not_only_a_string(self) -> None:
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="example",
name="Example",
version="1.0.0",
architecture=ModuleArchitectureDeclaration(
layer="domain_capability",
kind="domain",
maturity="supported",
evidence=(),
owned_concepts=("example",),
),
)
)
with self.assertRaisesRegex(RegistryError, "missing evidence"):
registry.validate()
def test_supported_and_lts_claims_require_reference_packages(self) -> None:
for maturity in ("supported", "lts"):
with self.subTest(maturity=maturity):
declaration = ModuleArchitectureDeclaration(
layer="domain_capability",
kind="domain",
maturity=maturity,
evidence=tuple(
ModuleMaturityEvidence(
kind=kind,
reference=f"evidence/{kind}.json",
summary=f"{kind} evidence.",
)
for kind in (
"test",
"documentation",
"reference_process",
"target_test",
"recovery",
"security",
"operations",
"accessibility",
"privacy",
"upgrade",
*(("compatibility",) if maturity == "lts" else ()),
)
),
owned_concepts=("example",),
documentation=ModuleArchitectureDocumentation(
upgrade=("docs/UPGRADE.md",),
recovery=("docs/RECOVERY.md",),
security=("docs/SECURITY.md",),
operations=("docs/OPERATIONS.md",),
),
)
self.assertEqual(
(f"{maturity} maturity requires a reference package",),
module_architecture_issues(declaration, has_migrations=False),
)
def test_reference_ready_claim_requires_target_human_and_operator_evidence(self) -> None:
declaration = ModuleArchitectureDeclaration(
layer="domain_capability",
kind="domain",
maturity="reference_ready",
evidence=(
ModuleMaturityEvidence(
kind="test",
reference="tests/test_reference.py",
summary="Automated journey proof.",
),
ModuleMaturityEvidence(
kind="documentation",
reference="docs/REFERENCE.md",
summary="Reference composition documentation.",
),
ModuleMaturityEvidence(
kind="reference_process",
reference="docs/JOURNEY.md",
summary="Reference process definition.",
),
ModuleMaturityEvidence(
kind="recovery",
reference="evidence/recovery.json",
summary="Recovery drill evidence.",
),
),
known_limits=("One provider profile remains bounded.",),
owned_concepts=("example",),
reference_packages=("reference.example",),
)
issues = module_architecture_issues(declaration, has_migrations=False)
self.assertIn("accessibility", issues[0])
self.assertIn("operations", issues[0])
self.assertIn("privacy", issues[0])
self.assertIn("security", issues[0])
self.assertIn("target_test", issues[0])
self.assertIn("missing documentation", issues[1])
def test_target_tested_provider_requires_provider_evidence(self) -> None:
evidence = tuple(
ModuleMaturityEvidence(
kind=kind,
reference=f"evidence/{kind}.json",
summary=f"{kind} evidence.",
)
for kind in (
"test",
"documentation",
"reference_process",
"target_test",
"recovery",
"security",
"operations",
"accessibility",
"privacy",
)
)
declaration = ModuleArchitectureDeclaration(
layer="data_reporting_integration",
kind="integration",
maturity="reference_ready",
evidence=evidence,
known_limits=("One bounded target profile is assessed.",),
reference_packages=("reference.integration",),
target_tested_providers=("connectors.example",),
documentation=ModuleArchitectureDocumentation(
recovery=("docs/RECOVERY.md",),
security=("docs/SECURITY.md",),
operations=("docs/OPERATIONS.md",),
),
)
self.assertEqual(
(
"maturity 'reference_ready' is missing evidence: provider",
),
module_architecture_issues(declaration, has_migrations=False),
)
def test_provider_references_must_exist_in_manifest(self) -> None:
provider = _mirror_provider()
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="connectors",
name="Connectors",
version="1.0.0",
architecture=_architecture(
providers=(provider.id,),
modes=("external_authoritative", "external_mirror"),
),
external_providers=(provider,),
)
)
with self.assertRaisesRegex(RegistryError, "undeclared capabilities"):
registry.validate()
def test_governed_sync_requires_read_and_write_semantics(self) -> None:
with self.assertRaisesRegex(
ArchitectureDeclarationError,
"read and write/synchronize",
):
ExternalProviderDeclaration(
id="broken.sync",
module_id="broken",
label="Broken sync",
maturity="synchronize",
operations=("synchronize",),
objects=(
ProviderObjectDeclaration(
object_type="object",
field_groups=("content",),
authority_modes=("governed_sync",),
default_authority_mode="governed_sync",
),
),
behavior=_writable_provider().behavior,
)
if __name__ == "__main__":
unittest.main()
+288
View File
@@ -0,0 +1,288 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
import pytest
from govoplan_core.core.recovery import (
RecoveryCheckpoint,
RecoveryGuaranteeError,
RecoveryMode,
RecoveryOperation,
RecoveryPlan,
RecoveryStatus,
plan_recovery_operation,
prepare_recovery_operation,
record_recovery_checkpoint,
start_recovery_operation,
transition_recovery_operation,
verify_recovery_evidence_chain,
)
from govoplan_core.core.runtime_coordination import DistributedLease, acquire_lease
from govoplan_core.db.base import Base
def _session() -> tuple[Session, object]:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(
engine,
tables=[
DistributedLease.__table__,
RecoveryOperation.__table__,
RecoveryCheckpoint.__table__,
],
)
return Session(engine, expire_on_commit=False), engine
def test_recovery_plan_requires_mode_specific_evidence() -> None:
with pytest.raises(RecoveryGuaranteeError, match="compensation steps"):
RecoveryPlan(
mode=RecoveryMode.COMPENSATION,
verification_steps=("verify",),
).validate()
with pytest.raises(RecoveryGuaranteeError, match="backup reference"):
RecoveryPlan(
mode=RecoveryMode.SNAPSHOT_RESTORE,
verification_steps=("verify",),
).validate()
with pytest.raises(RecoveryGuaranteeError, match="approval reference"):
RecoveryPlan(
mode=RecoveryMode.IRREVERSIBLE,
verification_steps=("verify",),
).validate()
def test_recovery_plan_hashes_backup_and_approval_references() -> None:
plan = RecoveryPlan(
mode=RecoveryMode.SNAPSHOT_RESTORE,
verification_steps=("restore drill passed",),
backup_reference="backup://postgres/2026-08-01",
approval_reference="approval://change/42",
)
assert plan.as_dict()["backup_reference"] == "backup://postgres/2026-08-01"
assert plan.as_dict()["approval_reference"] == "approval://change/42"
def test_recovery_operation_records_verifiable_checkpoint_chain() -> None:
session, engine = _session()
try:
operation = plan_recovery_operation(
session,
installation_id="installation-1",
module_id="files",
operation_type="move-tree",
idempotency_key="move-42",
request={"source": "a", "target": "b"},
recovery_plan=RecoveryPlan(
mode=RecoveryMode.COMPENSATION,
compensation_steps=("restore source links",),
verification_steps=("compare object inventory",),
),
)
prepare_recovery_operation(
session,
operation,
evidence={"inventory_sha256": "a" * 64},
)
start_recovery_operation(session, operation)
transition_recovery_operation(
session,
operation,
status=RecoveryStatus.RECOVERY_REQUIRED,
kind="side-effect-failed",
summary="Target index update failed",
evidence={"completed_steps": 1},
failure_summary="target index unavailable",
)
transition_recovery_operation(
session,
operation,
status=RecoveryStatus.RECOVERING,
kind="compensation-started",
summary="Compensation started",
)
transition_recovery_operation(
session,
operation,
status=RecoveryStatus.RECOVERED,
kind="compensation-verified",
summary="Source inventory restored",
evidence={
"verified": True,
"checks": {"object_inventory": "matched"},
},
)
session.commit()
assert operation.status == RecoveryStatus.RECOVERED.value
assert operation.checkpoint_count == 6
assert verify_recovery_evidence_chain(session, operation.id) is True
finally:
session.close()
engine.dispose()
def test_recovery_transition_cannot_skip_preparation() -> None:
session, engine = _session()
try:
operation = plan_recovery_operation(
session,
installation_id="installation-1",
module_id="core",
operation_type="configuration-change",
idempotency_key="config-1",
request={"revision": 2},
recovery_plan=RecoveryPlan(
mode=RecoveryMode.ATOMIC,
verification_steps=("read current revision",),
),
)
with pytest.raises(RecoveryGuaranteeError, match="not allowed"):
start_recovery_operation(session, operation)
finally:
session.close()
engine.dispose()
def test_non_atomic_failure_requires_explicit_recovery_and_verified_completion() -> (
None
):
session, engine = _session()
try:
operation = plan_recovery_operation(
session,
installation_id="installation-1",
module_id="campaign",
operation_type="artifact-publish",
idempotency_key="publish-1",
request={"version": "version-1"},
recovery_plan=RecoveryPlan(
mode=RecoveryMode.COMPENSATION,
compensation_steps=("delete published objects",),
verification_steps=("verify object inventory",),
),
)
prepare_recovery_operation(
session,
operation,
evidence={"preconditions": "verified"},
)
start_recovery_operation(session, operation)
with pytest.raises(RecoveryGuaranteeError, match="cannot be marked failed"):
transition_recovery_operation(
session,
operation,
status=RecoveryStatus.FAILED,
kind="failed",
summary="Object publication failed",
)
transition_recovery_operation(
session,
operation,
status=RecoveryStatus.RECOVERY_REQUIRED,
kind="recovery-required",
summary="Published objects require compensation",
)
transition_recovery_operation(
session,
operation,
status=RecoveryStatus.RECOVERING,
kind="compensation-started",
summary="Removing published objects",
)
with pytest.raises(RecoveryGuaranteeError, match="verified evidence"):
transition_recovery_operation(
session,
operation,
status=RecoveryStatus.RECOVERED,
kind="compensation-finished",
summary="Objects removed",
)
finally:
session.close()
engine.dispose()
def test_recovery_evidence_rejects_plaintext_secrets() -> None:
session, engine = _session()
try:
with pytest.raises(RecoveryGuaranteeError, match="plaintext secrets"):
plan_recovery_operation(
session,
installation_id="installation-1",
module_id="mail",
operation_type="transport-change",
idempotency_key="transport-1",
request={"transport_id": "smtp-1"},
metadata={"password": "do-not-store"},
recovery_plan=RecoveryPlan(
mode=RecoveryMode.ATOMIC,
verification_steps=("read persisted transport",),
),
)
finally:
session.close()
engine.dispose()
def test_fenced_recovery_rejects_low_level_mutation_without_claim() -> None:
session, engine = _session()
try:
claim = acquire_lease(
session,
installation_id="installation-1",
resource_key="files:move-tree",
holder_node_id="worker-1",
holder_incarnation="worker-1-incarnation",
ttl_seconds=300,
now=datetime.now(timezone.utc),
)
assert claim is not None
operation = plan_recovery_operation(
session,
installation_id="installation-1",
module_id="files",
operation_type="move-tree",
idempotency_key="move-fenced-1",
request={"source": "a", "target": "b"},
recovery_plan=RecoveryPlan(
mode=RecoveryMode.COMPENSATION,
compensation_steps=("restore source links",),
verification_steps=("compare object inventory",),
),
lease_claim=claim,
)
with pytest.raises(RecoveryGuaranteeError, match="distributed lease fence"):
transition_recovery_operation(
session,
operation,
status=RecoveryStatus.PREPARED,
kind="prepared",
summary="Attempted without fence",
evidence={"inventory_sha256": "a" * 64},
)
with pytest.raises(RecoveryGuaranteeError, match="distributed lease fence"):
record_recovery_checkpoint(
session,
operation,
kind="manual-note",
summary="Attempted without fence",
evidence={},
)
prepared = prepare_recovery_operation(
session,
operation,
evidence={"inventory_sha256": "a" * 64},
lease_claim=claim,
)
assert prepared.status == RecoveryStatus.PREPARED.value
finally:
session.close()
engine.dispose()
+125
View File
@@ -0,0 +1,125 @@
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from govoplan_core.core.runtime_coordination import (
RuntimeCoordinationError,
RuntimeIdentity,
)
from govoplan_core.server.runtime_agent import RuntimeNodeAgent
class _Session:
def __enter__(self):
return self
def __exit__(self, *_args) -> None:
return None
def commit(self) -> None:
return None
class _Database:
def SessionLocal(self) -> _Session:
return _Session()
class _Consumer:
def __init__(self) -> None:
self.cancelled: list[str] = []
self.added: list[str] = []
def cancel_task_queue(self, queue: str) -> None:
self.cancelled.append(queue)
def add_task_queue(self, queue: str) -> None:
self.added.append(queue)
def test_api_runtime_agent_fails_readiness_on_heartbeat_error() -> None:
agent = RuntimeNodeAgent(
settings=SimpleNamespace(
installation_id="installation-1",
runtime_role="api",
runtime_node_id="api-1",
runtime_heartbeat_seconds=15,
celery_queues="",
),
software_version="0.1.14",
module_ids=("core",),
)
agent.coordination_healthy = True
def fail() -> None:
raise RuntimeCoordinationError("database unavailable")
agent._heartbeat = fail
assert asyncio.run(agent._heartbeat_once()) is False
assert agent.coordination_healthy is False
agent._heartbeat = lambda: None
assert asyncio.run(agent._heartbeat_once()) is True
assert agent.coordination_healthy is True
def test_worker_disables_consumers_without_reclaiming_stale_identity(
monkeypatch,
) -> None:
from govoplan_core import celery_app
identity = RuntimeIdentity(
installation_id="installation-1",
node_id="worker-1",
incarnation="stale-incarnation",
role="worker",
software_version="0.1.14",
composition_hash="a" * 64,
queues=("default", "mail"),
)
consumer = _Consumer()
original_state = (
celery_app._worker_identity,
celery_app._worker_consumer,
celery_app._worker_draining,
)
celery_app._worker_identity = identity
celery_app._worker_consumer = consumer
celery_app._worker_draining = False
monkeypatch.setattr(celery_app, "get_database", lambda: _Database())
def reject_heartbeat(*_args, **_kwargs):
raise RuntimeCoordinationError("stale node incarnation")
monkeypatch.setattr(
celery_app,
"heartbeat_runtime_node",
reject_heartbeat,
)
monkeypatch.setattr(
celery_app,
"register_runtime_node",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
AssertionError("heartbeat failures must not reclaim an identity")
),
)
try:
celery_app._heartbeat_worker_runtime()
assert consumer.cancelled == ["default", "mail"]
assert celery_app._worker_draining is True
monkeypatch.setattr(
celery_app,
"heartbeat_runtime_node",
lambda *_args, **_kwargs: SimpleNamespace(state="active"),
)
celery_app._heartbeat_worker_runtime()
assert consumer.added == ["default", "mail"]
assert celery_app._worker_draining is False
finally:
(
celery_app._worker_identity,
celery_app._worker_consumer,
celery_app._worker_draining,
) = original_state
+130
View File
@@ -0,0 +1,130 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.runtime_coordination import (
DistributedLease,
RuntimeIdentity,
RuntimeNode,
RuntimeNodeState,
StaleFence,
acquire_lease,
assert_lease_fence,
heartbeat_runtime_node,
list_runtime_nodes,
register_runtime_node,
request_runtime_node_drain,
)
from govoplan_core.db.base import Base
def _session() -> tuple[Session, object]:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(
engine,
tables=[RuntimeNode.__table__, DistributedLease.__table__],
)
return Session(engine, expire_on_commit=False), engine
def _identity(*, node_id: str, incarnation: str) -> RuntimeIdentity:
return RuntimeIdentity(
installation_id="installation-1",
node_id=node_id,
incarnation=incarnation,
role="worker",
software_version="0.1.14",
composition_hash="a" * 64,
queues=("default",),
)
def test_node_incarnation_guards_heartbeat_and_drain_state() -> None:
session, engine = _session()
now = datetime(2026, 8, 1, 10, 0, tzinfo=timezone.utc)
first = _identity(node_id="worker-1", incarnation="first")
replacement = _identity(node_id="worker-1", incarnation="replacement")
try:
register_runtime_node(session, first, now=now)
heartbeat_runtime_node(session, first, now=now + timedelta(seconds=5))
register_runtime_node(session, replacement, now=now + timedelta(seconds=10))
with pytest.raises(RuntimeError, match="stale node incarnation"):
heartbeat_runtime_node(session, first, now=now + timedelta(seconds=15))
node = request_runtime_node_drain(
session,
installation_id="installation-1",
node_id="worker-1",
reason="rolling update",
now=now + timedelta(seconds=20),
)
assert node.state == RuntimeNodeState.DRAINING.value
rows = list_runtime_nodes(
session,
installation_id="installation-1",
stale_after_seconds=60,
now=now + timedelta(seconds=25),
)
assert rows[0]["incarnation"] == "replacement"
assert rows[0]["state"] == "draining"
assert rows[0]["stale"] is False
finally:
session.close()
engine.dispose()
def test_expired_lease_reassignment_increments_fence() -> None:
session, engine = _session()
now = datetime(2026, 8, 1, 10, 0, tzinfo=timezone.utc)
try:
first = acquire_lease(
session,
installation_id="installation-1",
resource_key="migration:release",
holder_node_id="node-a",
holder_incarnation="a1",
ttl_seconds=30,
now=now,
)
assert first is not None and first.fencing_token == 1
blocked = acquire_lease(
session,
installation_id="installation-1",
resource_key="migration:release",
holder_node_id="node-b",
holder_incarnation="b1",
ttl_seconds=30,
now=now + timedelta(seconds=10),
)
assert blocked is None
replacement = acquire_lease(
session,
installation_id="installation-1",
resource_key="migration:release",
holder_node_id="node-b",
holder_incarnation="b1",
ttl_seconds=30,
now=now + timedelta(seconds=31),
)
assert replacement is not None
assert replacement.fencing_token == 2
with pytest.raises(StaleFence):
assert_lease_fence(
session,
first,
now=now + timedelta(seconds=32),
)
assert_lease_fence(
session,
replacement,
now=now + timedelta(seconds=32),
)
finally:
session.close()
engine.dispose()
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from govoplan_core.commands.wait_for_database import main
class WaitForDatabaseTests(unittest.TestCase):
def test_waits_until_exact_configured_heads_are_visible(self) -> None:
with (
patch(
"govoplan_core.commands.wait_for_database.configured_migration_heads",
return_value=("head-a", "head-b"),
),
patch(
"govoplan_core.commands.wait_for_database.database_migration_heads",
side_effect=[("head-a",), ("head-a", "head-b")],
),
patch("govoplan_core.commands.wait_for_database.time.sleep"),
):
result = main(["--timeout-seconds", "1", "--poll-seconds", "0.01"])
self.assertEqual(0, result)
def test_returns_temporary_failure_when_heads_do_not_match(self) -> None:
with (
patch(
"govoplan_core.commands.wait_for_database.configured_migration_heads",
return_value=("head-b",),
),
patch(
"govoplan_core.commands.wait_for_database.database_migration_heads",
return_value=("head-a",),
),
):
result = main(["--timeout-seconds", "0"])
self.assertEqual(75, result)
if __name__ == "__main__":
unittest.main()
+146
View File
@@ -14,19 +14,26 @@
"@govoplan/audit-webui": "file:../../govoplan-audit/webui",
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
"@govoplan/cases-webui": "file:../../govoplan-cases/webui",
"@govoplan/committee-webui": "file:../../govoplan-committee/webui",
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
"@govoplan/dataflow-webui": "file:../../govoplan-dataflow/webui",
"@govoplan/datasources-webui": "file:../../govoplan-datasources/webui",
"@govoplan/dist-lists-webui": "file:../../govoplan-dist-lists/webui",
"@govoplan/docs-webui": "file:../../govoplan-docs/webui",
"@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/forms-runtime-webui": "file:../../govoplan-forms-runtime/webui",
"@govoplan/forms-webui": "file:../../govoplan-forms/webui",
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
"@govoplan/notifications-webui": "file:../../govoplan-notifications/webui",
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
"@govoplan/portal-webui": "file:../../govoplan-portal/webui",
"@govoplan/postbox-webui": "file:../../govoplan-postbox/webui",
"@govoplan/projects-webui": "file:../../govoplan-projects/webui",
"@govoplan/reporting-webui": "file:../../govoplan-reporting/webui",
"@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui",
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
"@govoplan/search-webui": "file:../../govoplan-search/webui",
@@ -170,6 +177,38 @@
}
}
},
"../../govoplan-cases/webui": {
"name": "@govoplan/cases-webui",
"version": "0.1.8",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-committee/webui": {
"name": "@govoplan/committee-webui",
"version": "0.1.8",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-dashboard/webui": {
"name": "@govoplan/dashboard-webui",
"version": "0.1.8",
@@ -276,6 +315,37 @@
}
}
},
"../../govoplan-forms-runtime/webui": {
"name": "@govoplan/forms-runtime-webui",
"version": "0.1.14",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-forms/webui": {
"name": "@govoplan/forms-webui",
"version": "0.1.14",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-idm/webui": {
"name": "@govoplan/idm-webui",
"version": "0.1.8",
@@ -387,6 +457,22 @@
}
}
},
"../../govoplan-portal/webui": {
"name": "@govoplan/portal-webui",
"version": "0.1.8",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-postbox/webui": {
"name": "@govoplan/postbox-webui",
"version": "0.1.2",
@@ -403,6 +489,38 @@
}
}
},
"../../govoplan-projects/webui": {
"name": "@govoplan/projects-webui",
"version": "0.1.14",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-reporting/webui": {
"name": "@govoplan/reporting-webui",
"version": "0.1.14",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-risk-compliance/webui": {
"name": "@govoplan/risk-compliance-webui",
"version": "0.1.8",
@@ -1279,6 +1397,14 @@
"resolved": "../../govoplan-campaign/webui",
"link": true
},
"node_modules/@govoplan/cases-webui": {
"resolved": "../../govoplan-cases/webui",
"link": true
},
"node_modules/@govoplan/committee-webui": {
"resolved": "../../govoplan-committee/webui",
"link": true
},
"node_modules/@govoplan/dashboard-webui": {
"resolved": "../../govoplan-dashboard/webui",
"link": true
@@ -1303,6 +1429,14 @@
"resolved": "../../govoplan-files/webui",
"link": true
},
"node_modules/@govoplan/forms-runtime-webui": {
"resolved": "../../govoplan-forms-runtime/webui",
"link": true
},
"node_modules/@govoplan/forms-webui": {
"resolved": "../../govoplan-forms/webui",
"link": true
},
"node_modules/@govoplan/idm-webui": {
"resolved": "../../govoplan-idm/webui",
"link": true
@@ -1327,10 +1461,22 @@
"resolved": "../../govoplan-policy/webui",
"link": true
},
"node_modules/@govoplan/portal-webui": {
"resolved": "../../govoplan-portal/webui",
"link": true
},
"node_modules/@govoplan/postbox-webui": {
"resolved": "../../govoplan-postbox/webui",
"link": true
},
"node_modules/@govoplan/projects-webui": {
"resolved": "../../govoplan-projects/webui",
"link": true
},
"node_modules/@govoplan/reporting-webui": {
"resolved": "../../govoplan-reporting/webui",
"link": true
},
"node_modules/@govoplan/risk-compliance-webui": {
"resolved": "../../govoplan-risk-compliance/webui",
"link": true
+7
View File
@@ -53,20 +53,27 @@
"@govoplan/admin-webui": "file:../../govoplan-admin/webui",
"@govoplan/audit-webui": "file:../../govoplan-audit/webui",
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
"@govoplan/cases-webui": "file:../../govoplan-cases/webui",
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
"@govoplan/committee-webui": "file:../../govoplan-committee/webui",
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
"@govoplan/dataflow-webui": "file:../../govoplan-dataflow/webui",
"@govoplan/datasources-webui": "file:../../govoplan-datasources/webui",
"@govoplan/dist-lists-webui": "file:../../govoplan-dist-lists/webui",
"@govoplan/docs-webui": "file:../../govoplan-docs/webui",
"@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/forms-webui": "file:../../govoplan-forms/webui",
"@govoplan/forms-runtime-webui": "file:../../govoplan-forms-runtime/webui",
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
"@govoplan/notifications-webui": "file:../../govoplan-notifications/webui",
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
"@govoplan/portal-webui": "file:../../govoplan-portal/webui",
"@govoplan/postbox-webui": "file:../../govoplan-postbox/webui",
"@govoplan/projects-webui": "file:../../govoplan-projects/webui",
"@govoplan/reporting-webui": "file:../../govoplan-reporting/webui",
"@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui",
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
"@govoplan/search-webui": "file:../../govoplan-search/webui",
+15 -1
View File
@@ -7,20 +7,27 @@ const packageByModule = {
addresses: "@govoplan/addresses-webui",
audit: "@govoplan/audit-webui",
calendar: "@govoplan/calendar-webui",
cases: "@govoplan/cases-webui",
campaigns: "@govoplan/campaign-webui",
committee: "@govoplan/committee-webui",
dashboard: "@govoplan/dashboard-webui",
dataflow: "@govoplan/dataflow-webui",
datasources: "@govoplan/datasources-webui",
dist_lists: "@govoplan/dist-lists-webui",
docs: "@govoplan/docs-webui",
files: "@govoplan/files-webui",
forms: "@govoplan/forms-webui",
forms_runtime: "@govoplan/forms-runtime-webui",
idm: "@govoplan/idm-webui",
mail: "@govoplan/mail-webui",
notifications: "@govoplan/notifications-webui",
organizations: "@govoplan/organizations-webui",
ops: "@govoplan/ops-webui",
policy: "@govoplan/policy-webui",
portal: "@govoplan/portal-webui",
postbox: "@govoplan/postbox-webui",
projects: "@govoplan/projects-webui",
reporting: "@govoplan/reporting-webui",
risk_compliance: "@govoplan/risk-compliance-webui",
scheduling: "@govoplan/scheduling-webui",
search: "@govoplan/search-webui",
@@ -49,12 +56,19 @@ const cases = [
{ name: "views-only", modules: ["views"] },
{ name: "views-with-administration", modules: ["access", "admin", "views"] },
{ name: "calendar-only", modules: ["calendar"] },
{ name: "cases-only", modules: ["cases"] },
{ name: "committee-only", modules: ["committee"] },
{ name: "files-only", modules: ["files"] },
{ name: "forms-only", modules: ["forms"] },
{ name: "forms-runtime", modules: ["forms", "forms_runtime"] },
{ name: "mail-only", modules: ["mail"] },
{ name: "notifications-only", modules: ["notifications"] },
{ name: "organizations-only", modules: ["organizations"] },
{ name: "idm-with-organizations", modules: ["organizations", "idm"] },
{ name: "postbox-only", modules: ["postbox"] },
{ name: "portal-only", modules: ["portal"] },
{ name: "projects-only", modules: ["projects"] },
{ name: "reporting-only", modules: ["reporting"] },
{ name: "campaign-only", modules: ["campaigns"] },
{ name: "campaign-with-postbox", modules: ["campaigns", "postbox"] },
{ name: "campaign-with-files-no-mail", modules: ["campaigns", "files"] },
@@ -64,7 +78,7 @@ const cases = [
{ name: "search-only", modules: ["search"] },
{ name: "risk-compliance-only", modules: ["risk_compliance"] },
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
{ name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "dist_lists", "workflow", "views", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "postbox", "risk_compliance", "search"] }
{ name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "dist_lists", "workflow", "views", "organizations", "idm", "cases", "committee", "campaigns", "files", "forms", "forms_runtime", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "portal", "postbox", "projects", "reporting", "risk_compliance", "search"] }
];
const npmExec = process.env.npm_execpath;
+5 -1
View File
@@ -1,4 +1,4 @@
import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, DatabaseZap, Folder, Form, Inbox, LayoutDashboard, LayoutTemplate, ListTree, Mail, Mails, RadioTower, Shield, ShieldCheck, Users, Waypoints, Workflow as WorkflowIcon, type LucideIcon } from "lucide-react";
import { Activity, Bell, BookUser, BriefcaseBusiness, Building2, CalendarClock, CalendarDays, ClipboardPenLine, DatabaseZap, Folder, FolderKanban, Form, Gavel, Inbox, Landmark, LayoutDashboard, LayoutTemplate, ListTree, Mail, Mails, RadioTower, Shield, ShieldCheck, Users, Waypoints, Workflow as WorkflowIcon, type LucideIcon } from "lucide-react";
import installedWebModuleLoaderSource from "virtual:govoplan-installed-modules";
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, EffectiveViewProjection, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformViewSurface, PlatformWebModule } from "../types";
import {
@@ -56,6 +56,7 @@ const iconByName: Record<string, LucideIcon> = {
admin: Shield,
bell: Bell,
"book-user": BookUser,
"briefcase-business": BriefcaseBusiness,
building: Building2,
"building-2": Building2,
calendar: CalendarDays,
@@ -67,8 +68,11 @@ const iconByName: Record<string, LucideIcon> = {
file: Folder,
files: Folder,
folder: Folder,
"folder-kanban": FolderKanban,
form: Form,
gavel: Gavel,
inbox: Inbox,
landmark: Landmark,
"layout-template": LayoutTemplate,
"list-tree": ListTree,
mail: Mail,
+25
View File
@@ -1057,6 +1057,31 @@ export type PlatformModuleInfo = {
dependencies: string[];
optional_dependencies: string[];
enabled: boolean;
architecture?: {
contract_version: string;
layer: string;
kind: string;
maturity: string;
evidence: Array<{ kind: string; reference: string; summary: string }>;
known_limits: string[];
supported_authority_modes: string[];
owned_concepts: string[];
non_owned_concepts: string[];
reference_packages: string[];
target_tested_providers: string[];
documentation: Record<string, string[]>;
} | null;
external_providers?: Array<{
contract_version: string;
id: string;
module_id: string;
label: string;
maturity: string;
operations: string[];
authority_modes: string[];
objects: Array<Record<string, unknown>>;
behavior: Record<string, unknown>;
}>;
runtime_ui_capabilities?: string[];
nav: Array<{
path: string;
+11
View File
@@ -18,19 +18,26 @@ const defaultWebModulePackages = [
"@govoplan/addresses-webui",
"@govoplan/audit-webui",
"@govoplan/calendar-webui",
"@govoplan/cases-webui",
"@govoplan/campaign-webui",
"@govoplan/committee-webui",
"@govoplan/dataflow-webui",
"@govoplan/datasources-webui",
"@govoplan/dashboard-webui",
"@govoplan/docs-webui",
"@govoplan/files-webui",
"@govoplan/forms-webui",
"@govoplan/forms-runtime-webui",
"@govoplan/idm-webui",
"@govoplan/mail-webui",
"@govoplan/notifications-webui",
"@govoplan/organizations-webui",
"@govoplan/ops-webui",
"@govoplan/policy-webui",
"@govoplan/portal-webui",
"@govoplan/postbox-webui",
"@govoplan/projects-webui",
"@govoplan/reporting-webui",
"@govoplan/risk-compliance-webui",
"@govoplan/scheduling-webui",
"@govoplan/search-webui",
@@ -221,11 +228,14 @@ export default defineConfig({
fileURLToPath(new URL('../../govoplan-addresses/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-audit/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-calendar/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-cases/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-dataflow/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-datasources/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-dashboard/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-docs/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-files/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-forms/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-forms-runtime/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-idm/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-mail/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-notifications/webui', import.meta.url)),
@@ -233,6 +243,7 @@ export default defineConfig({
fileURLToPath(new URL('../../govoplan-campaign/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-ops/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-policy/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-portal/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-postbox/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-risk-compliance/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-scheduling/webui', import.meta.url)),