From 2b4eb0151f2e3bb530c80c924c69734567f02d8c Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 1 Aug 2026 20:57:25 +0200 Subject: [PATCH] Add governed institutional capability contracts --- src/govoplan_core/celery_app.py | 42 +- src/govoplan_core/core/approvals.py | 207 ++++++ src/govoplan_core/core/encryption.py | 692 ++++++++++++++++++++ src/govoplan_core/core/identity_trust.py | 321 ++++++++++ src/govoplan_core/core/institutional.py | 694 ++++++++++++++------- src/govoplan_core/core/voting.py | 223 +++++++ src/govoplan_core/core/workflows.py | 59 +- tests/test_encryption_contract.py | 96 +++ tests/test_module_system.py | 4 +- tests/test_platform_event_worker.py | 16 +- tests/test_workflow_runtime_worker.py | 42 +- webui/package-lock.json | 40 ++ webui/package.json | 2 + webui/scripts/test-module-permutations.mjs | 6 +- webui/src/platform/modules.ts | 4 +- webui/vite.config.ts | 4 + 16 files changed, 2203 insertions(+), 249 deletions(-) create mode 100644 src/govoplan_core/core/approvals.py create mode 100644 src/govoplan_core/core/encryption.py create mode 100644 src/govoplan_core/core/identity_trust.py create mode 100644 src/govoplan_core/core/voting.py create mode 100644 tests/test_encryption_contract.py diff --git a/src/govoplan_core/celery_app.py b/src/govoplan_core/celery_app.py index 49e28b2..e6f5b36 100644 --- a/src/govoplan_core/celery_app.py +++ b/src/govoplan_core/celery_app.py @@ -53,7 +53,9 @@ from govoplan_core.core.postbox import ( ) from govoplan_core.core.workflows import ( CAPABILITY_WORKFLOW_RUNTIME_WORKER, + CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER, WorkflowRuntimeWorker, + WorkflowTriggerDispatcher, ) from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.runtime import configure_runtime @@ -385,6 +387,18 @@ def _workflow_runtime_worker( return capability +def _workflow_trigger_dispatcher( + registry: PlatformRegistry | None = None, +) -> WorkflowTriggerDispatcher | None: + registry = registry or _platform_registry() + if not registry.has_capability(CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER): + return None + capability = registry.require_capability(CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER) + if not isinstance(capability, WorkflowTriggerDispatcher): + raise RuntimeError("Workflow trigger dispatcher capability is invalid") + return capability + + def _postbox_routing_provider( registry: PlatformRegistry | None = None, ) -> PostboxRoutingProvider | None: @@ -764,7 +778,8 @@ def dispatch_platform_events(self, limit: int = 100): "observer_failed": 0, } dataflow_dispatcher = _dataflow_trigger_dispatcher(registry) - consumers = () + workflow_dispatcher = _workflow_trigger_dispatcher(registry) + consumers: list[DurableEventConsumer] = [] if dataflow_dispatcher is not None: def deliver_to_dataflow( @@ -776,19 +791,38 @@ def dispatch_platform_events(self, limit: int = 100): event=event, ) - consumers = ( + consumers.append( DurableEventConsumer( consumer_id="dataflow.event-triggers.v1", event_types=frozenset({"*"}), classifications=frozenset({"public", "internal"}), handler=deliver_to_dataflow, - ), + ) + ) + if workflow_dispatcher is not None: + + def deliver_to_workflow( + event: PlatformEvent, + _delivery_key: str, + ) -> None: + workflow_dispatcher.ingest_event( + session, + event=event, + ) + + consumers.append( + DurableEventConsumer( + consumer_id="workflow.event-triggers.v1", + event_types=frozenset({"*"}), + classifications=frozenset({"public", "internal"}), + handler=deliver_to_workflow, + ) ) result = dict( outbox.dispatch_pending( session, - consumers=consumers, + consumers=tuple(consumers), observer=publish_platform_event, limit=limit, ) diff --git a/src/govoplan_core/core/approvals.py b/src/govoplan_core/core/approvals.py new file mode 100644 index 0000000..0898a57 --- /dev/null +++ b/src/govoplan_core/core/approvals.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from datetime import datetime +from typing import Protocol, runtime_checkable + + +CAPABILITY_APPROVAL_REQUESTS = "approvals.requests" + + +class ApprovalCapabilityError(ValueError): + """Stable error raised by Approval capability implementations.""" + + +@dataclass(frozen=True, slots=True) +class ApprovalActorSelector: + kind: str + value: str + label: str | None = None + + +@dataclass(frozen=True, slots=True) +class ApprovalStepDefinition: + key: str + label: str + selectors: tuple[ApprovalActorSelector, ...] + required_approvals: int = 1 + rejection_policy: str = "fail_fast" + due_at: datetime | None = None + signature_required: bool = False + forbidden_evidence_roles: tuple[str, ...] = () + metadata: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class ApprovalRequestCreateCommand: + title: str + subject_module: str + subject_type: str + subject_id: str + subject_version: str | None + subject_digest: str + steps: tuple[ApprovalStepDefinition, ...] + description: str | None = None + separation_of_duties: bool = True + unique_actors_across_steps: bool = False + expires_at: datetime | None = None + policy_refs: tuple[str, ...] = () + evidence_actors: Mapping[str, tuple[str, ...]] = field(default_factory=dict) + template_id: str | None = None + template_revision: int | None = None + metadata: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class ApprovalTemplateCreateCommand: + key: str + title: str + steps: tuple[ApprovalStepDefinition, ...] + description: str | None = None + separation_of_duties: bool = True + unique_actors_across_steps: bool = False + metadata: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class ApprovalTemplateRef: + id: str + key: str + revision: int + state: str + content_sha256: str + + +@dataclass(frozen=True, slots=True) +class ApprovalDecisionCommand: + outcome: str + reason: str + expected_revision: int + idempotency_key: str + delegated_for_account_id: str | None = None + signature_ref: Mapping[str, object] | None = None + + +@dataclass(frozen=True, slots=True) +class ApprovalRequestRef: + id: str + revision: int + state: str + current_step_key: str | None = None + + +@dataclass(frozen=True, slots=True) +class ApprovalDecisionReceipt: + request_id: str + revision: int + state: str + step_key: str + outcome: str + actor_id: str + recorded_at: datetime + receipt_sha256: str + authority_provenance: Mapping[str, object] = field(default_factory=dict) + replayed: bool = False + + +@dataclass(frozen=True, slots=True) +class ApprovalCheck: + request_id: str + revision: int + state: str + approved: bool + subject_module: str + subject_type: str + subject_id: str + subject_version: str | None + subject_digest: str + completed_at: datetime | None = None + + +@runtime_checkable +class ApprovalRequestProvider(Protocol): + def create_template( + self, + session: object, + principal: object, + *, + command: ApprovalTemplateCreateCommand, + idempotency_key: str, + ) -> ApprovalTemplateRef: ... + + def revise_template( + self, + session: object, + principal: object, + *, + template_id: str, + command: ApprovalTemplateCreateCommand, + expected_revision: int, + idempotency_key: str, + ) -> ApprovalTemplateRef: ... + + def publish_template( + self, + session: object, + principal: object, + *, + template_id: str, + expected_revision: int, + idempotency_key: str, + ) -> ApprovalTemplateRef: ... + + def create_request( + self, + session: object, + principal: object, + *, + command: ApprovalRequestCreateCommand, + idempotency_key: str, + ) -> ApprovalRequestRef: ... + + def get_request( + self, + session: object, + principal: object, + *, + request_id: str, + ) -> Mapping[str, object] | None: ... + + def decide( + self, + session: object, + principal: object, + *, + request_id: str, + command: ApprovalDecisionCommand, + ) -> ApprovalDecisionReceipt: ... + + def check_approved( + self, + session: object, + principal: object, + *, + request_id: str, + subject_module: str, + subject_type: str, + subject_id: str, + subject_version: str | None, + subject_digest: str, + ) -> ApprovalCheck: ... + + +__all__ = [ + "ApprovalActorSelector", + "ApprovalCapabilityError", + "ApprovalCheck", + "ApprovalDecisionCommand", + "ApprovalDecisionReceipt", + "ApprovalRequestCreateCommand", + "ApprovalRequestProvider", + "ApprovalRequestRef", + "ApprovalStepDefinition", + "ApprovalTemplateCreateCommand", + "ApprovalTemplateRef", + "CAPABILITY_APPROVAL_REQUESTS", +] diff --git a/src/govoplan_core/core/encryption.py b/src/govoplan_core/core/encryption.py new file mode 100644 index 0000000..9d68c29 --- /dev/null +++ b/src/govoplan_core/core/encryption.py @@ -0,0 +1,692 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from datetime import datetime +from typing import Literal, Protocol, runtime_checkable + + +CAPABILITY_ENCRYPTION_KEY_VAULT = "encryption.keyVault" +CAPABILITY_ENCRYPTION_CONTENT_PROTECTION = "encryption.contentProtection" +CAPABILITY_ENCRYPTION_RECOVERY = "encryption.recoveryCeremony" +CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT = "encryption.disablePreflight" +CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX = "encryption.keyMaterialProvider." +ENCRYPTION_CONTRACT_VERSION = "1" + +ProtectionProfileKind = Literal[ + "server_envelope", + "tenant_held", + "end_to_end", +] +KeyLifecycleState = Literal[ + "active", + "rotation_pending", + "revoked", + "destruction_scheduled", + "destroyed", + "unavailable", +] +ProtectionState = Literal[ + "active", + "migration_pending", + "migrating", + "migrated", + "decrypted", + "exported", + "destroyed", + "unavailable", +] + + +@dataclass(frozen=True, slots=True) +class KeyMaterialProvisionRequest: + tenant_id: str + vault_id: str + key_version: int + algorithm_suite: str + purpose: str + idempotency_key: str + provider_policy: Mapping[str, object] = field(default_factory=dict) + import_reference: str | None = None + contract_version: str = ENCRYPTION_CONTRACT_VERSION + + def __post_init__(self) -> None: + _validate_contract(self.contract_version) + if self.key_version < 1: + raise ValueError("Key version must be positive") + for label, value in ( + ("tenant id", self.tenant_id), + ("vault id", self.vault_id), + ("algorithm suite", self.algorithm_suite), + ("purpose", self.purpose), + ("idempotency key", self.idempotency_key), + ): + _require_text(value, label) + + +@dataclass(frozen=True, slots=True) +class KeyMaterialDescriptor: + provider_id: str + provider_key_ref: str + algorithm_suite: str + state: KeyLifecycleState + created_at: datetime + public_key_ref: str | None = None + imported: bool = False + exportable: bool = False + provider_version: str | None = None + provenance: Mapping[str, object] = field(default_factory=dict) + contract_version: str = ENCRYPTION_CONTRACT_VERSION + + def __post_init__(self) -> None: + _validate_contract(self.contract_version) + for label, value in ( + ("provider id", self.provider_id), + ("provider key reference", self.provider_key_ref), + ("algorithm suite", self.algorithm_suite), + ): + _require_text(value, label) + _reject_secret_mapping(self.provenance, "Provider provenance") + + +@dataclass(frozen=True, slots=True) +class KeyVaultCreateRequest: + tenant_id: str + vault_id: str + name: str + provider_id: str + purpose: str + algorithm_suite: str + scope_type: str + scope_id: str | None + policy_ref: str + idempotency_key: str + recovery_quorum: int = 2 + profile_kind: ProtectionProfileKind = "server_envelope" + import_reference: str | None = None + provider_policy: Mapping[str, object] = field(default_factory=dict) + contract_version: str = ENCRYPTION_CONTRACT_VERSION + + def __post_init__(self) -> None: + _validate_contract(self.contract_version) + if self.recovery_quorum < 1: + raise ValueError("Recovery quorum must be positive") + for label, value in ( + ("tenant id", self.tenant_id), + ("vault id", self.vault_id), + ("name", self.name), + ("provider id", self.provider_id), + ("purpose", self.purpose), + ("algorithm suite", self.algorithm_suite), + ("scope type", self.scope_type), + ("policy reference", self.policy_ref), + ("idempotency key", self.idempotency_key), + ): + _require_text(value, label) + _reject_secret_mapping(self.provider_policy, "Provider policy") + + +@dataclass(frozen=True, slots=True) +class KeyVersionRef: + tenant_id: str + vault_id: str + version: int + provider_id: str + provider_key_ref: str + algorithm_suite: str + state: KeyLifecycleState + created_at: datetime + activated_at: datetime | None = None + revoked_at: datetime | None = None + destruction_scheduled_at: datetime | None = None + destroyed_at: datetime | None = None + public_key_ref: str | None = None + imported: bool = False + exportable: bool = False + provenance: Mapping[str, object] = field(default_factory=dict) + contract_version: str = ENCRYPTION_CONTRACT_VERSION + + +@dataclass(frozen=True, slots=True) +class KeyVaultRef: + tenant_id: str + vault_id: str + name: str + provider_id: str + purpose: str + profile_kind: ProtectionProfileKind + scope_type: str + scope_id: str | None + policy_ref: str + recovery_quorum: int + state: str + revision: int + current_key: KeyVersionRef | None + created_at: datetime + updated_at: datetime + provenance: Mapping[str, object] = field(default_factory=dict) + contract_version: str = ENCRYPTION_CONTRACT_VERSION + + +@dataclass(frozen=True, slots=True) +class KeyRotationRequest: + tenant_id: str + vault_id: str + expected_revision: int + reason: str + policy_decision_ref: str + assurance_evidence_ref: str + idempotency_key: str + algorithm_suite: str | None = None + provider_policy: Mapping[str, object] = field(default_factory=dict) + contract_version: str = ENCRYPTION_CONTRACT_VERSION + + def __post_init__(self) -> None: + _validate_contract(self.contract_version) + if self.expected_revision < 1: + raise ValueError("Expected revision must be positive") + for label, value in ( + ("tenant id", self.tenant_id), + ("vault id", self.vault_id), + ("reason", self.reason), + ("policy decision reference", self.policy_decision_ref), + ("assurance evidence reference", self.assurance_evidence_ref), + ("idempotency key", self.idempotency_key), + ): + _require_text(value, label) + _reject_secret_mapping(self.provider_policy, "Provider policy") + + +@dataclass(frozen=True, slots=True) +class KeyLifecycleRequest: + tenant_id: str + vault_id: str + key_version: int + expected_revision: int + reason: str + policy_decision_ref: str + assurance_evidence_ref: str + idempotency_key: str + effective_at: datetime | None = None + contract_version: str = ENCRYPTION_CONTRACT_VERSION + + def __post_init__(self) -> None: + _validate_contract(self.contract_version) + if self.key_version < 1 or self.expected_revision < 1: + raise ValueError("Key version and expected revision must be positive") + for label, value in ( + ("tenant id", self.tenant_id), + ("vault id", self.vault_id), + ("reason", self.reason), + ("policy decision reference", self.policy_decision_ref), + ("assurance evidence reference", self.assurance_evidence_ref), + ("idempotency key", self.idempotency_key), + ): + _require_text(value, label) + + +@dataclass(frozen=True, slots=True) +class ContentProtectionEnvelope: + envelope_id: str + tenant_id: str + owner_module: str + resource_type: str + resource_id: str + profile_kind: ProtectionProfileKind + profile_id: str + provider_id: str + vault_id: str + key_version: int + algorithm_suite: str + ciphertext_ref: str + ciphertext_digest: str + authenticated_context_digest: str + state: ProtectionState + created_at: datetime + wrapped_key_refs: tuple[str, ...] = () + source_envelope_id: str | None = None + migration_id: str | None = None + metadata: Mapping[str, object] = field(default_factory=dict) + contract_version: str = ENCRYPTION_CONTRACT_VERSION + + def __post_init__(self) -> None: + _validate_contract(self.contract_version) + if self.key_version < 1: + raise ValueError("Envelope key version must be positive") + for label, value in ( + ("envelope id", self.envelope_id), + ("tenant id", self.tenant_id), + ("owner module", self.owner_module), + ("resource type", self.resource_type), + ("resource id", self.resource_id), + ("profile id", self.profile_id), + ("provider id", self.provider_id), + ("vault id", self.vault_id), + ("algorithm suite", self.algorithm_suite), + ("ciphertext reference", self.ciphertext_ref), + ("ciphertext digest", self.ciphertext_digest), + ("authenticated context digest", self.authenticated_context_digest), + ): + _require_text(value, label) + _reject_secret_mapping(self.metadata, "Protection metadata") + + +@dataclass(frozen=True, slots=True) +class ProtectionRegistrationRequest: + envelope: ContentProtectionEnvelope + idempotency_key: str + policy_decision_ref: str + contract_version: str = ENCRYPTION_CONTRACT_VERSION + + def __post_init__(self) -> None: + _validate_contract(self.contract_version) + _require_text(self.idempotency_key, "idempotency key") + _require_text(self.policy_decision_ref, "policy decision reference") + + +@dataclass(frozen=True, slots=True) +class ProtectionMigrationRequest: + tenant_id: str + envelope_id: str + target_provider_id: str + target_vault_id: str + target_key_version: int + target_algorithm_suite: str + mode: Literal["rewrap", "reencrypt", "decrypt", "export", "destroy"] + policy_decision_ref: str + assurance_evidence_ref: str + idempotency_key: str + contract_version: str = ENCRYPTION_CONTRACT_VERSION + + def __post_init__(self) -> None: + _validate_contract(self.contract_version) + if self.target_key_version < 1: + raise ValueError("Target key version must be positive") + for label, value in ( + ("tenant id", self.tenant_id), + ("envelope id", self.envelope_id), + ("target provider id", self.target_provider_id), + ("target vault id", self.target_vault_id), + ("target algorithm suite", self.target_algorithm_suite), + ("policy decision reference", self.policy_decision_ref), + ("assurance evidence reference", self.assurance_evidence_ref), + ("idempotency key", self.idempotency_key), + ): + _require_text(value, label) + + +@dataclass(frozen=True, slots=True) +class ProtectionMigrationResult: + migration_id: str + state: Literal[ + "requested", + "running", + "succeeded", + "rejected", + "outcome_unknown", + "reconciled", + ] + source_envelope_id: str + target_envelope: ContentProtectionEnvelope | None = None + error: str | None = None + evidence_refs: tuple[str, ...] = () + provenance: Mapping[str, object] = field(default_factory=dict) + contract_version: str = ENCRYPTION_CONTRACT_VERSION + + +@dataclass(frozen=True, slots=True) +class RecoveryRequest: + tenant_id: str + vault_id: str + reason: str + requested_scope: str + policy_decision_ref: str + assurance_evidence_ref: str + idempotency_key: str + expires_at: datetime + contract_version: str = ENCRYPTION_CONTRACT_VERSION + + def __post_init__(self) -> None: + _validate_contract(self.contract_version) + for label, value in ( + ("tenant id", self.tenant_id), + ("vault id", self.vault_id), + ("reason", self.reason), + ("requested scope", self.requested_scope), + ("policy decision reference", self.policy_decision_ref), + ("assurance evidence reference", self.assurance_evidence_ref), + ("idempotency key", self.idempotency_key), + ): + _require_text(value, label) + + +@dataclass(frozen=True, slots=True) +class RecoveryApprovalRequest: + tenant_id: str + recovery_id: str + decision: Literal["approve", "reject"] + reason: str + assurance_evidence_ref: str + expected_revision: int + idempotency_key: str + contract_version: str = ENCRYPTION_CONTRACT_VERSION + + def __post_init__(self) -> None: + _validate_contract(self.contract_version) + if self.expected_revision < 1: + raise ValueError("Expected revision must be positive") + for label, value in ( + ("tenant id", self.tenant_id), + ("recovery id", self.recovery_id), + ("reason", self.reason), + ("assurance evidence reference", self.assurance_evidence_ref), + ("idempotency key", self.idempotency_key), + ): + _require_text(value, label) + + +@dataclass(frozen=True, slots=True) +class RecoveryRef: + tenant_id: str + recovery_id: str + vault_id: str + state: Literal["pending", "approved", "rejected", "expired", "executed"] + requested_scope: str + quorum: int + approvals: int + rejections: int + revision: int + expires_at: datetime + policy_decision_ref: str + execution_ref: str | None = None + provenance: Mapping[str, object] = field(default_factory=dict) + contract_version: str = ENCRYPTION_CONTRACT_VERSION + + +@dataclass(frozen=True, slots=True) +class DisablePreflightReport: + allowed: bool + protected_count: int + unresolved_count: int + state_counts: Mapping[str, int] + blocking_envelope_refs: tuple[str, ...] + required_actions: tuple[str, ...] + generated_at: datetime + contract_version: str = ENCRYPTION_CONTRACT_VERSION + + +@runtime_checkable +class EncryptionKeyMaterialProvider(Protocol): + def provision_key( + self, + session: object, + principal: object, + *, + request: KeyMaterialProvisionRequest, + ) -> KeyMaterialDescriptor: ... + + def schedule_key_destruction( + self, + session: object, + principal: object, + *, + provider_key_ref: str, + effective_at: datetime, + idempotency_key: str, + ) -> KeyMaterialDescriptor: ... + + def revoke_key( + self, + session: object, + principal: object, + *, + provider_key_ref: str, + reason: str, + idempotency_key: str, + ) -> KeyMaterialDescriptor: ... + + def key_status( + self, + session: object, + *, + provider_key_ref: str, + ) -> KeyMaterialDescriptor | None: ... + + +@runtime_checkable +class EncryptionKeyVault(Protocol): + def create_vault( + self, + session: object, + principal: object, + *, + request: KeyVaultCreateRequest, + ) -> KeyVaultRef: ... + + def rotate_key( + self, + session: object, + principal: object, + *, + request: KeyRotationRequest, + ) -> KeyVaultRef: ... + + def revoke_key( + self, + session: object, + principal: object, + *, + request: KeyLifecycleRequest, + ) -> KeyVaultRef: ... + + def schedule_destruction( + self, + session: object, + principal: object, + *, + request: KeyLifecycleRequest, + ) -> KeyVaultRef: ... + + def get_vault( + self, + session: object, + *, + tenant_id: str, + vault_id: str, + ) -> KeyVaultRef | None: ... + + def reconcile_vault( + self, + session: object, + principal: object, + *, + tenant_id: str, + vault_id: str, + ) -> KeyVaultRef: ... + + +@runtime_checkable +class ContentProtectionRegistry(Protocol): + def register_envelope( + self, + session: object, + principal: object, + *, + request: ProtectionRegistrationRequest, + ) -> ContentProtectionEnvelope: ... + + def request_migration( + self, + session: object, + principal: object, + *, + request: ProtectionMigrationRequest, + ) -> ProtectionMigrationResult: ... + + def reconcile_migration( + self, + session: object, + principal: object, + *, + migration_id: str, + ) -> ProtectionMigrationResult: ... + + def get_envelope( + self, + session: object, + principal: object, + *, + tenant_id: str, + envelope_id: str, + ) -> ContentProtectionEnvelope | None: ... + + def record_migration_outcome( + self, + session: object, + principal: object, + *, + migration_id: str, + state: Literal["succeeded", "rejected", "outcome_unknown"], + evidence_refs: tuple[str, ...], + target_envelope: ContentProtectionEnvelope | None = None, + error_code: str | None = None, + ) -> ProtectionMigrationResult: ... + + +@runtime_checkable +class EncryptionRecoveryCeremony(Protocol): + def request_recovery( + self, + session: object, + principal: object, + *, + request: RecoveryRequest, + ) -> RecoveryRef: ... + + def decide_recovery( + self, + session: object, + principal: object, + *, + request: RecoveryApprovalRequest, + ) -> RecoveryRef: ... + + +@runtime_checkable +class EncryptionDisablePreflight(Protocol): + def assess_disable( + self, + session: object, + *, + tenant_id: str | None = None, + limit: int = 100, + ) -> DisablePreflightReport: ... + + +def key_material_provider( + registry: object | None, + provider_id: str, +) -> EncryptionKeyMaterialProvider | None: + capability = _capability( + registry, + f"{CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX}{provider_id}", + ) + return capability if isinstance(capability, EncryptionKeyMaterialProvider) else None + + +def encryption_key_vault(registry: object | None) -> EncryptionKeyVault | None: + value = _capability(registry, CAPABILITY_ENCRYPTION_KEY_VAULT) + return value if isinstance(value, EncryptionKeyVault) else None + + +def content_protection_registry( + registry: object | None, +) -> ContentProtectionRegistry | None: + value = _capability(registry, CAPABILITY_ENCRYPTION_CONTENT_PROTECTION) + return value if isinstance(value, ContentProtectionRegistry) else None + + +def encryption_recovery_ceremony( + registry: object | None, +) -> EncryptionRecoveryCeremony | None: + value = _capability(registry, CAPABILITY_ENCRYPTION_RECOVERY) + return value if isinstance(value, EncryptionRecoveryCeremony) else None + + +def encryption_disable_preflight( + registry: object | None, +) -> EncryptionDisablePreflight | None: + value = _capability(registry, CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT) + return value if isinstance(value, EncryptionDisablePreflight) else None + + +def _capability(registry: object | None, name: str) -> object | None: + if ( + registry is None + or not hasattr(registry, "has_capability") + or not hasattr(registry, "capability") + or not registry.has_capability(name) + ): + return None + return registry.capability(name) + + +def _validate_contract(value: str) -> None: + if value != ENCRYPTION_CONTRACT_VERSION: + raise ValueError("Unsupported encryption contract version") + + +def _require_text(value: str, label: str) -> None: + if not value.strip(): + raise ValueError(f"{label.capitalize()} is required") + + +def _reject_secret_mapping(value: Mapping[str, object], label: str) -> None: + forbidden = { + "ciphertext", + "credential", + "key", + "key_material", + "password", + "plaintext", + "private_key", + "secret", + "token", + } + keys = {str(key).strip().casefold() for key in value} + if forbidden & keys: + raise ValueError(f"{label} must not contain secret material") + + +__all__ = [ + "CAPABILITY_ENCRYPTION_CONTENT_PROTECTION", + "CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT", + "CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX", + "CAPABILITY_ENCRYPTION_KEY_VAULT", + "CAPABILITY_ENCRYPTION_RECOVERY", + "ContentProtectionEnvelope", + "ContentProtectionRegistry", + "DisablePreflightReport", + "EncryptionDisablePreflight", + "EncryptionKeyMaterialProvider", + "EncryptionKeyVault", + "EncryptionRecoveryCeremony", + "KeyLifecycleRequest", + "KeyMaterialDescriptor", + "KeyMaterialProvisionRequest", + "KeyRotationRequest", + "KeyVaultCreateRequest", + "KeyVaultRef", + "KeyVersionRef", + "ProtectionMigrationRequest", + "ProtectionMigrationResult", + "ProtectionRegistrationRequest", + "RecoveryApprovalRequest", + "RecoveryRef", + "RecoveryRequest", + "content_protection_registry", + "encryption_disable_preflight", + "encryption_key_vault", + "encryption_recovery_ceremony", + "key_material_provider", +] diff --git a/src/govoplan_core/core/identity_trust.py b/src/govoplan_core/core/identity_trust.py new file mode 100644 index 0000000..fc3f78d --- /dev/null +++ b/src/govoplan_core/core/identity_trust.py @@ -0,0 +1,321 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from datetime import datetime +from typing import Literal, Protocol, runtime_checkable + + +CAPABILITY_IDENTITY_TRUST_DIRECTORY = "identity_trust.directory" +CAPABILITY_IDENTITY_TRUST_ASSURANCE = "identity_trust.assurance" +IDENTITY_TRUST_CONTRACT_VERSION = "1" + +DeviceKeyPurpose = Literal["encryption", "signing", "encryption_and_signing"] +DeviceKeyStatus = Literal["active", "revoked", "expired"] +TrustSubjectKind = Literal[ + "identity", + "account", + "function", + "postbox", + "external_recipient", +] + +_PRIVATE_JWK_FIELDS = frozenset({"d", "p", "q", "dp", "dq", "qi", "oth", "k"}) + + +@dataclass(frozen=True, slots=True) +class DeviceKeyRegistration: + tenant_id: str + identity_id: str + account_id: str + device_id: str + key_id: str + algorithm: str + public_jwk: Mapping[str, object] + purpose: DeviceKeyPurpose = "encryption" + assurance_level: str = "software" + attestation_ref: str | None = None + expires_at: datetime | None = None + idempotency_key: str = "" + contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION + + def __post_init__(self) -> None: + _validate_contract(self.contract_version) + for label, value in ( + ("tenant id", self.tenant_id), + ("identity id", self.identity_id), + ("account id", self.account_id), + ("device id", self.device_id), + ("key id", self.key_id), + ("algorithm", self.algorithm), + ("idempotency key", self.idempotency_key), + ): + _require_text(value, label) + if not self.public_jwk or _PRIVATE_JWK_FIELDS & set(self.public_jwk): + raise ValueError("Only a bounded public JWK may be registered") + + +@dataclass(frozen=True, slots=True) +class DeviceKeyRef: + tenant_id: str + identity_id: str + account_id: str + device_id: str + key_id: str + algorithm: str + public_jwk: Mapping[str, object] + purpose: DeviceKeyPurpose + assurance_level: str + status: DeviceKeyStatus + epoch: int + registered_at: datetime + attestation_ref: str | None = None + expires_at: datetime | None = None + revoked_at: datetime | None = None + revocation_reason: str | None = None + provenance: Mapping[str, object] = field(default_factory=dict) + contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION + + +@dataclass(frozen=True, slots=True) +class KeyEpochRotationRequest: + tenant_id: str + subject_kind: TrustSubjectKind + subject_id: str + reason: str + access_decision_ref: str + idempotency_key: str + history_policy: str = "all_retained" + previous_epoch: int | None = None + contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION + + def __post_init__(self) -> None: + _validate_contract(self.contract_version) + for label, value in ( + ("tenant id", self.tenant_id), + ("subject id", self.subject_id), + ("reason", self.reason), + ("access decision reference", self.access_decision_ref), + ("idempotency key", self.idempotency_key), + ): + _require_text(value, label) + + +@dataclass(frozen=True, slots=True) +class KeyEpochRef: + tenant_id: str + subject_kind: TrustSubjectKind + subject_id: str + epoch: int + state: Literal["active", "superseded", "revoked"] + history_policy: str + effective_at: datetime + previous_epoch: int | None = None + reason: str | None = None + access_decision_ref: str | None = None + provenance: Mapping[str, object] = field(default_factory=dict) + contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION + + +@dataclass(frozen=True, slots=True) +class KeyAccessRequest: + tenant_id: str + account_id: str + device_key_id: str + subject_kind: TrustSubjectKind + subject_id: str + key_epoch: int + access_decision_ref: str + purpose: str + requested_at: datetime + function_assignment_id: str | None = None + delegation_id: str | None = None + resource_ref: str | None = None + contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION + + def __post_init__(self) -> None: + _validate_contract(self.contract_version) + if self.key_epoch < 1: + raise ValueError("Key epoch must be positive") + for label, value in ( + ("tenant id", self.tenant_id), + ("account id", self.account_id), + ("device key id", self.device_key_id), + ("subject id", self.subject_id), + ("access decision reference", self.access_decision_ref), + ("purpose", self.purpose), + ): + _require_text(value, label) + + +@dataclass(frozen=True, slots=True) +class KeyAccessDecision: + allowed: bool + decision_ref: str + reason: str + device_key: DeviceKeyRef | None = None + epoch: KeyEpochRef | None = None + audit_event_ref: str | None = None + requirements: tuple[str, ...] = () + provenance: Mapping[str, object] = field(default_factory=dict) + contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION + + +@dataclass(frozen=True, slots=True) +class AssuranceCheckRequest: + tenant_id: str + account_id: str + purpose: str + minimum_level: str + evidence_ref: str + evaluated_at: datetime + maximum_age_seconds: int = 300 + device_key_id: str | None = None + contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION + + def __post_init__(self) -> None: + _validate_contract(self.contract_version) + if self.maximum_age_seconds < 1: + raise ValueError("Assurance maximum age must be positive") + for label, value in ( + ("tenant id", self.tenant_id), + ("account id", self.account_id), + ("purpose", self.purpose), + ("minimum level", self.minimum_level), + ("evidence reference", self.evidence_ref), + ): + _require_text(value, label) + + +@dataclass(frozen=True, slots=True) +class AssuranceDecision: + allowed: bool + reason: str + assurance_level: str | None = None + evidence_ref: str | None = None + verified_at: datetime | None = None + expires_at: datetime | None = None + provenance: Mapping[str, object] = field(default_factory=dict) + contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION + + +@runtime_checkable +class IdentityTrustDirectory(Protocol): + def register_device_key( + self, + session: object, + principal: object, + *, + request: DeviceKeyRegistration, + ) -> DeviceKeyRef: ... + + def revoke_device_key( + self, + session: object, + principal: object, + *, + tenant_id: str, + key_id: str, + expected_epoch: int, + reason: str, + ) -> DeviceKeyRef: ... + + def list_device_keys( + self, + session: object, + principal: object, + *, + tenant_id: str, + account_id: str, + active_only: bool = True, + ) -> tuple[DeviceKeyRef, ...]: ... + + def rotate_epoch( + self, + session: object, + principal: object, + *, + request: KeyEpochRotationRequest, + ) -> KeyEpochRef: ... + + def resolve_epoch( + self, + session: object, + *, + tenant_id: str, + subject_kind: TrustSubjectKind, + subject_id: str, + epoch: int | None = None, + ) -> KeyEpochRef | None: ... + + def decide_key_access( + self, + session: object, + principal: object, + *, + request: KeyAccessRequest, + ) -> KeyAccessDecision: ... + + +@runtime_checkable +class IdentityTrustAssurance(Protocol): + def verify_assurance( + self, + session: object, + principal: object, + *, + request: AssuranceCheckRequest, + ) -> AssuranceDecision: ... + + +def identity_trust_directory( + registry: object | None, +) -> IdentityTrustDirectory | None: + capability = _capability(registry, CAPABILITY_IDENTITY_TRUST_DIRECTORY) + return capability if isinstance(capability, IdentityTrustDirectory) else None + + +def identity_trust_assurance( + registry: object | None, +) -> IdentityTrustAssurance | None: + capability = _capability(registry, CAPABILITY_IDENTITY_TRUST_ASSURANCE) + return capability if isinstance(capability, IdentityTrustAssurance) else None + + +def _capability(registry: object | None, name: str) -> object | None: + if ( + registry is None + or not hasattr(registry, "has_capability") + or not hasattr(registry, "capability") + or not registry.has_capability(name) + ): + return None + return registry.capability(name) + + +def _validate_contract(value: str) -> None: + if value != IDENTITY_TRUST_CONTRACT_VERSION: + raise ValueError("Unsupported identity-trust contract version") + + +def _require_text(value: str, label: str) -> None: + if not value.strip(): + raise ValueError(f"{label.capitalize()} is required") + + +__all__ = [ + "AssuranceCheckRequest", + "AssuranceDecision", + "CAPABILITY_IDENTITY_TRUST_ASSURANCE", + "CAPABILITY_IDENTITY_TRUST_DIRECTORY", + "DeviceKeyRef", + "DeviceKeyRegistration", + "IdentityTrustAssurance", + "IdentityTrustDirectory", + "KeyAccessDecision", + "KeyAccessRequest", + "KeyEpochRef", + "KeyEpochRotationRequest", + "identity_trust_assurance", + "identity_trust_directory", +] diff --git a/src/govoplan_core/core/institutional.py b/src/govoplan_core/core/institutional.py index c9979de..5f18e77 100644 --- a/src/govoplan_core/core/institutional.py +++ b/src/govoplan_core/core/institutional.py @@ -66,9 +66,7 @@ EvidenceKind = Literal[ ] PartySubjectKind = Literal["identity", "organization", "group", "external"] MandateStatus = Literal["draft", "active", "suspended", "replaced", "retired"] -ServicePublicationState = Literal[ - "draft", "published", "suspended", "retired" -] +ServicePublicationState = Literal["draft", "published", "suspended", "retired"] ServiceAvailabilityRequirementKind = Literal[ "module", "capability", @@ -97,9 +95,21 @@ FormValueType = Literal[ "list", ] FormSignatureRequirement = Literal["none", "optional", "required"] -ProcedurePartyStatus = Literal[ - "active", "revoked", "expired", "superseded" +FormConditionKind = Literal["predicate", "all", "any", "not"] +FormConditionOperator = Literal[ + "eq", + "neq", + "lt", + "lte", + "gt", + "gte", + "in", + "not_in", + "contains", + "is_empty", + "is_not_empty", ] +ProcedurePartyStatus = Literal["active", "revoked", "expired", "superseded"] DecisionState = Literal[ "draft", "proposed", @@ -150,12 +160,8 @@ _EVIDENCE_KINDS = frozenset( "other", } ) -_PARTY_SUBJECT_KINDS = frozenset( - {"identity", "organization", "group", "external"} -) -_MANDATE_STATUSES = frozenset( - {"draft", "active", "suspended", "replaced", "retired"} -) +_PARTY_SUBJECT_KINDS = frozenset({"identity", "organization", "group", "external"}) +_MANDATE_STATUSES = frozenset({"draft", "active", "suspended", "replaced", "retired"}) _MANDATE_TRANSITIONS: Mapping[MandateStatus, frozenset[MandateStatus]] = { "draft": frozenset({"active", "retired"}), "active": frozenset({"suspended", "replaced", "retired"}), @@ -163,9 +169,7 @@ _MANDATE_TRANSITIONS: Mapping[MandateStatus, frozenset[MandateStatus]] = { "replaced": frozenset(), "retired": frozenset(), } -_SERVICE_PUBLICATION_STATES = frozenset( - {"draft", "published", "suspended", "retired"} -) +_SERVICE_PUBLICATION_STATES = frozenset({"draft", "published", "suspended", "retired"}) _FORM_PUBLICATION_STATES = frozenset({"draft", "published", "retired"}) _FORM_VALUE_TYPES = frozenset( { @@ -184,6 +188,22 @@ _FORM_VALUE_TYPES = frozenset( } ) _FORM_SIGNATURE_REQUIREMENTS = frozenset({"none", "optional", "required"}) +_FORM_CONDITION_KINDS = frozenset({"predicate", "all", "any", "not"}) +_FORM_CONDITION_OPERATORS = frozenset( + { + "eq", + "neq", + "lt", + "lte", + "gt", + "gte", + "in", + "not_in", + "contains", + "is_empty", + "is_not_empty", + } +) _SERVICE_AVAILABILITY_REQUIREMENT_KINDS = frozenset( { "module", @@ -196,9 +216,7 @@ _SERVICE_AVAILABILITY_REQUIREMENT_KINDS = frozenset( "configuration", } ) -_PROCEDURE_PARTY_STATUSES = frozenset( - {"active", "revoked", "expired", "superseded"} -) +_PROCEDURE_PARTY_STATUSES = frozenset({"active", "revoked", "expired", "superseded"}) _PROCEDURE_PARTY_TRANSITIONS: Mapping[ ProcedurePartyStatus, frozenset[ProcedurePartyStatus] ] = { @@ -287,9 +305,8 @@ class TemporalRevision: def effective_at(self, instant: datetime) -> bool: _require_aware_datetime(instant, "effective instant") - return ( - (self.valid_from is None or instant >= self.valid_from) - and (self.valid_to is None or instant < self.valid_to) + return (self.valid_from is None or instant >= self.valid_from) and ( + self.valid_to is None or instant < self.valid_to ) def to_dict(self) -> dict[str, object]: @@ -680,13 +697,9 @@ class ActorRepresentationReference: "identity_id": self.identity_id, "service_account_id": self.service_account_id, "represented_account_id": self.represented_account_id, - "represented_function_ref": _reference_value( - self.represented_function_ref - ), + "represented_function_ref": _reference_value(self.represented_function_ref), "represented_party_ref": _reference_value(self.represented_party_ref), - "function_assignment_ref": _reference_value( - self.function_assignment_ref - ), + "function_assignment_ref": _reference_value(self.function_assignment_ref), "delegation_ref": self.delegation_ref, "power_ref": self.power_ref, "mandate_ref": _reference_value(self.mandate_ref), @@ -701,9 +714,7 @@ class ActorRepresentationReference: account_id=_optional_text(value.get("account_id")), identity_id=_optional_text(value.get("identity_id")), service_account_id=_optional_text(value.get("service_account_id")), - represented_account_id=_optional_text( - value.get("represented_account_id") - ), + represented_account_id=_optional_text(value.get("represented_account_id")), represented_function_ref=_optional_institutional_reference( value.get("represented_function_ref") ), @@ -715,9 +726,7 @@ class ActorRepresentationReference: ), delegation_ref=_optional_text(value.get("delegation_ref")), power_ref=_optional_text(value.get("power_ref")), - mandate_ref=_optional_institutional_reference( - value.get("mandate_ref") - ), + mandate_ref=_optional_institutional_reference(value.get("mandate_ref")), ) @@ -793,9 +802,7 @@ class PresentationReference: def from_mapping(cls, value: Mapping[str, object]) -> "PresentationReference": return cls( language=_optional_text(value.get("language")), - accessibility_profile=_optional_text( - value.get("accessibility_profile") - ), + accessibility_profile=_optional_text(value.get("accessibility_profile")), channel=_optional_text(value.get("channel")), explanation_ref=_optional_text(value.get("explanation_ref")), availability_ref=_optional_text(value.get("availability_ref")), @@ -852,9 +859,7 @@ class GovernedContextEnvelope: _validate_reference_kind(item, "jurisdiction", "jurisdiction reference") for item in self.party_refs: _validate_reference_kind(item, "party", "party reference") - _validate_reference_kind( - self.work_item_ref, "work_item", "work item reference" - ) + _validate_reference_kind(self.work_item_ref, "work_item", "work item reference") _validate_reference_kind(self.workflow_ref, "workflow", "workflow reference") for item in self.approval_refs: _validate_reference_kind(item, "approval", "approval reference") @@ -908,15 +913,11 @@ class GovernedContextEnvelope: "temporal": self.temporal.to_dict(), "actor": self.actor.to_dict() if self.actor else None, "institution_ref": _reference_value(self.institution_ref), - "organization_unit_ref": _reference_value( - self.organization_unit_ref - ), + "organization_unit_ref": _reference_value(self.organization_unit_ref), "function_ref": _reference_value(self.function_ref), "task_ref": _reference_value(self.task_ref), "mandate_ref": _reference_value(self.mandate_ref), - "jurisdiction_refs": [ - item.to_dict() for item in self.jurisdiction_refs - ], + "jurisdiction_refs": [item.to_dict() for item in self.jurisdiction_refs], "service_ref": _reference_value(self.service_ref), "case_ref": _reference_value(self.case_ref), "party_refs": [item.to_dict() for item in self.party_refs], @@ -951,8 +952,7 @@ class GovernedContextEnvelope: ) return cls( contract_version=str( - value.get("contract_version") - or INSTITUTIONAL_CONTEXT_CONTRACT_VERSION + value.get("contract_version") or INSTITUTIONAL_CONTEXT_CONTRACT_VERSION ), tenant_id=_required_text(value, "tenant_id"), temporal=TemporalRevision.from_mapping(temporal), @@ -963,31 +963,17 @@ class GovernedContextEnvelope: organization_unit_ref=_optional_institutional_reference( value.get("organization_unit_ref") ), - function_ref=_optional_institutional_reference( - value.get("function_ref") - ), + function_ref=_optional_institutional_reference(value.get("function_ref")), task_ref=_optional_institutional_reference(value.get("task_ref")), - mandate_ref=_optional_institutional_reference( - value.get("mandate_ref") - ), - jurisdiction_refs=_institutional_references( - value.get("jurisdiction_refs") - ), - service_ref=_optional_institutional_reference( - value.get("service_ref") - ), + mandate_ref=_optional_institutional_reference(value.get("mandate_ref")), + jurisdiction_refs=_institutional_references(value.get("jurisdiction_refs")), + service_ref=_optional_institutional_reference(value.get("service_ref")), case_ref=_optional_institutional_reference(value.get("case_ref")), party_refs=_institutional_references(value.get("party_refs")), - work_item_ref=_optional_institutional_reference( - value.get("work_item_ref") - ), - workflow_ref=_optional_institutional_reference( - value.get("workflow_ref") - ), + work_item_ref=_optional_institutional_reference(value.get("work_item_ref")), + workflow_ref=_optional_institutional_reference(value.get("workflow_ref")), approval_refs=_institutional_references(value.get("approval_refs")), - decision_ref=_optional_institutional_reference( - value.get("decision_ref") - ), + decision_ref=_optional_institutional_reference(value.get("decision_ref")), record_refs=_institutional_references(value.get("record_refs")), legal_bases=tuple( LegalBasisReference.from_mapping(item) @@ -1048,9 +1034,7 @@ class MandateDefinition: "replaced mandate reference", ) _validate_text_tuple(self.conflict_refs, "Mandate conflict references") - if self.status == "suspended" and not str( - self.suspension_reason or "" - ).strip(): + if self.status == "suspended" and not str(self.suspension_reason or "").strip(): raise InstitutionalContextError( "Suspended mandates require a suspension reason." ) @@ -1083,9 +1067,7 @@ class MandateDefinition: item.to_dict() for item in self.organization_unit_refs ], "function_refs": [item.to_dict() for item in self.function_refs], - "jurisdiction_refs": [ - item.to_dict() for item in self.jurisdiction_refs - ], + "jurisdiction_refs": [item.to_dict() for item in self.jurisdiction_refs], "subject_types": list(self.subject_types), "authority_ceiling": self.authority_ceiling, "legal_bases": [ @@ -1115,9 +1097,7 @@ class MandateDefinition: value.get("organization_unit_refs") ), function_refs=_institutional_references(value.get("function_refs")), - jurisdiction_refs=_institutional_references( - value.get("jurisdiction_refs") - ), + jurisdiction_refs=_institutional_references(value.get("jurisdiction_refs")), subject_types=_text_tuple(value.get("subject_types")), authority_ceiling=_optional_text(value.get("authority_ceiling")), legal_bases=tuple( @@ -1157,9 +1137,7 @@ def revise_mandate_definition( raise InstitutionalContextError( "A Mandate revision requires a new revision identifier." ) - if temporal.recorded_at is None or not str( - temporal.change_reason or "" - ).strip(): + if temporal.recorded_at is None or not str(temporal.change_reason or "").strip(): raise InstitutionalContextError( "A Mandate revision requires recorded_at and change_reason." ) @@ -1182,13 +1160,9 @@ def revise_mandate_definition( temporal=temporal, status=status, replacement_ref=replacement_ref if status == "replaced" else None, - suspension_reason=( - suspension_reason if status == "suspended" else None - ), + suspension_reason=(suspension_reason if status == "suspended" else None), conflict_refs=( - tuple(conflict_refs) - if conflict_refs is not None - else current.conflict_refs + tuple(conflict_refs) if conflict_refs is not None else current.conflict_refs ), ) @@ -1241,21 +1215,15 @@ class MandateResolutionRequest: "effective_at": self.effective_at.isoformat(), "task_type": self.task_type, "authority_type": self.authority_type, - "organization_unit_ref": _reference_value( - self.organization_unit_ref - ), + "organization_unit_ref": _reference_value(self.organization_unit_ref), "function_ref": _reference_value(self.function_ref), - "jurisdiction_refs": [ - item.to_dict() for item in self.jurisdiction_refs - ], + "jurisdiction_refs": [item.to_dict() for item in self.jurisdiction_refs], "subject_type": self.subject_type, "geo_ref": self.geo_ref.to_dict() if self.geo_ref else None, } @classmethod - def from_mapping( - cls, value: Mapping[str, object] - ) -> "MandateResolutionRequest": + def from_mapping(cls, value: Mapping[str, object]) -> "MandateResolutionRequest": geo = value.get("geo_ref") return cls( tenant_id=_required_text(value, "tenant_id"), @@ -1265,14 +1233,12 @@ class MandateResolutionRequest: organization_unit_ref=_optional_institutional_reference( value.get("organization_unit_ref") ), - function_ref=_optional_institutional_reference( - value.get("function_ref") - ), - jurisdiction_refs=_institutional_references( - value.get("jurisdiction_refs") - ), + function_ref=_optional_institutional_reference(value.get("function_ref")), + jurisdiction_refs=_institutional_references(value.get("jurisdiction_refs")), subject_type=_optional_text(value.get("subject_type")), - geo_ref=GeoReference.from_mapping(geo) if isinstance(geo, Mapping) else None, + geo_ref=GeoReference.from_mapping(geo) + if isinstance(geo, Mapping) + else None, ) @@ -1358,15 +1324,11 @@ def resolve_mandate_candidates( "Mandate resolution candidates cannot cross tenants." ) effective = tuple( - mandate - for mandate in ordered - if _mandate_matches_request(mandate, request) + mandate for mandate in ordered if _mandate_matches_request(mandate, request) ) candidate_conflicts = tuple( dict.fromkeys( - conflict - for mandate in effective - for conflict in mandate.conflict_refs + conflict for mandate in effective for conflict in mandate.conflict_refs ) ) if candidate_conflicts: @@ -1439,8 +1401,7 @@ def _mandate_matches_request( }.issubset(requested_jurisdictions): return False return not ( - mandate.subject_types - and request.subject_type not in mandate.subject_types + mandate.subject_types and request.subject_type not in mandate.subject_types ) @@ -1620,9 +1581,7 @@ class ServiceDefinition: ) for item in self.jurisdiction_refs: _validate_reference_kind(item, "jurisdiction", "service jurisdiction") - requirement_keys = tuple( - item.key for item in self.availability_requirements - ) + requirement_keys = tuple(item.key for item in self.availability_requirements) if len(requirement_keys) != len(set(requirement_keys)): raise InstitutionalContextError( "Service availability requirements cannot contain duplicates." @@ -1657,13 +1616,9 @@ class ServiceDefinition: "responsible_organization_ref": _reference_value( self.responsible_organization_ref ), - "responsible_function_ref": _reference_value( - self.responsible_function_ref - ), + "responsible_function_ref": _reference_value(self.responsible_function_ref), "mandate_ref": _reference_value(self.mandate_ref), - "jurisdiction_refs": [ - item.to_dict() for item in self.jurisdiction_refs - ], + "jurisdiction_refs": [item.to_dict() for item in self.jurisdiction_refs], "bindings": [item.to_dict() for item in self.bindings], "availability_requirements": [ item.to_dict() for item in self.availability_requirements @@ -1692,9 +1647,7 @@ class ServiceDefinition: LegalBasisReference.from_mapping(item) for item in _mapping_sequence(value.get("legal_bases")) ), - required_evidence_types=_text_tuple( - value.get("required_evidence_types") - ), + required_evidence_types=_text_tuple(value.get("required_evidence_types")), fee_refs=_text_tuple(value.get("fee_refs")), deadline_refs=_text_tuple(value.get("deadline_refs")), channels=_text_tuple(value.get("channels")), @@ -1704,21 +1657,15 @@ class ServiceDefinition: responsible_function_ref=_optional_institutional_reference( value.get("responsible_function_ref") ), - mandate_ref=_optional_institutional_reference( - value.get("mandate_ref") - ), - jurisdiction_refs=_institutional_references( - value.get("jurisdiction_refs") - ), + mandate_ref=_optional_institutional_reference(value.get("mandate_ref")), + jurisdiction_refs=_institutional_references(value.get("jurisdiction_refs")), bindings=tuple( ServiceBinding.from_mapping(item) for item in _mapping_sequence(value.get("bindings")) ), availability_requirements=tuple( ServiceAvailabilityRequirement.from_mapping(item) - for item in _mapping_sequence( - value.get("availability_requirements") - ) + for item in _mapping_sequence(value.get("availability_requirements")) ), remedy_refs=_text_tuple(value.get("remedy_refs")), service_level_refs=_text_tuple(value.get("service_level_refs")), @@ -1754,22 +1701,17 @@ def derive_service_restriction( _validate_reference_kind(reference, "service", "derived service definition") if reference.tenant_id != parent.reference.tenant_id: - raise InstitutionalContextError( - "A Service restriction cannot cross tenants." - ) + raise InstitutionalContextError("A Service restriction cannot cross tenants.") if reference.version != temporal.revision: raise InstitutionalContextError( "Derived Service reference version must match its temporal revision." ) - if temporal.recorded_at is None or not str( - temporal.change_reason or "" - ).strip(): + if temporal.recorded_at is None or not str(temporal.change_reason or "").strip(): raise InstitutionalContextError( "A derived Service requires recorded_at and change_reason." ) if parent.temporal.valid_from is not None and ( - temporal.valid_from is None - or temporal.valid_from < parent.temporal.valid_from + temporal.valid_from is None or temporal.valid_from < parent.temporal.valid_from ): raise InstitutionalContextError( "A derived Service cannot start before its parent." @@ -1786,9 +1728,7 @@ def derive_service_restriction( raise InstitutionalContextError( "A derived Service cannot widen its parent audience." ) - narrowed_channels = ( - tuple(channels) if channels is not None else parent.channels - ) + narrowed_channels = tuple(channels) if channels is not None else parent.channels if not set(narrowed_channels).issubset(parent.channels): raise InstitutionalContextError( "A derived Service cannot widen its parent channels." @@ -1803,9 +1743,10 @@ def derive_service_restriction( "suspended": frozenset({"suspended", "retired"}), "retired": frozenset({"retired"}), } - if next_publication_state not in allowed_publication_states[ - parent.publication_state - ]: + if ( + next_publication_state + not in allowed_publication_states[parent.publication_state] + ): raise InstitutionalContextError( "A derived Service cannot widen its parent publication state." ) @@ -1859,6 +1800,297 @@ class ServiceDefinitionProvider(Protocol): ) -> Sequence[ServiceDefinition]: ... +@dataclass(frozen=True, slots=True) +class FormConditionExpression: + kind: FormConditionKind + field_key: str | None = None + operator: FormConditionOperator | None = None + value: object | None = None + conditions: tuple["FormConditionExpression", ...] = () + + def __post_init__(self) -> None: + if self.kind not in _FORM_CONDITION_KINDS: + raise InstitutionalContextError( + f"Unsupported Form condition kind: {self.kind!r}." + ) + if self.kind == "predicate": + if self.field_key is None or self.operator is None: + raise InstitutionalContextError( + "A Form predicate requires a field key and operator." + ) + _require_identifier(self.field_key, "Form condition field key") + if self.operator not in _FORM_CONDITION_OPERATORS: + raise InstitutionalContextError( + f"Unsupported Form condition operator: {self.operator!r}." + ) + if self.conditions: + raise InstitutionalContextError( + "A Form predicate cannot contain nested conditions." + ) + if self.operator in {"is_empty", "is_not_empty"} and self.value is not None: + raise InstitutionalContextError( + f"Form condition operator {self.operator!r} does not take a value." + ) + return + if ( + self.field_key is not None + or self.operator is not None + or self.value is not None + ): + raise InstitutionalContextError( + "A composite Form condition cannot declare predicate properties." + ) + expected = 1 if self.kind == "not" else 2 + if len(self.conditions) < expected or ( + self.kind == "not" and len(self.conditions) != 1 + ): + raise InstitutionalContextError( + f"Form condition {self.kind!r} requires " + f"{'exactly one child' if self.kind == 'not' else 'at least two children'}." + ) + + def to_dict(self) -> dict[str, object]: + if self.kind == "predicate": + payload: dict[str, object] = { + "kind": self.kind, + "field_key": self.field_key, + "operator": self.operator, + } + if self.operator not in {"is_empty", "is_not_empty"}: + payload["value"] = self.value + return payload + return { + "kind": self.kind, + "conditions": [item.to_dict() for item in self.conditions], + } + + @classmethod + def from_mapping(cls, value: Mapping[str, object]) -> "FormConditionExpression": + return cls( + kind=cast(FormConditionKind, _required_text(value, "kind")), + field_key=_optional_text(value.get("field_key")), + operator=cast( + FormConditionOperator | None, + _optional_text(value.get("operator")), + ), + value=value.get("value"), + conditions=tuple( + cls.from_mapping(item) + for item in _mapping_sequence(value.get("conditions")) + ), + ) + + @property + def referenced_fields(self) -> tuple[str, ...]: + if self.kind == "predicate": + return (str(self.field_key),) + return tuple( + field_key + for condition in self.conditions + for field_key in condition.referenced_fields + ) + + +@dataclass(frozen=True, slots=True) +class FormSectionDefinition: + key: str + title: str + field_keys: tuple[str, ...] + description: str | None = None + visibility_condition: FormConditionExpression | None = None + + def __post_init__(self) -> None: + _require_identifier(self.key, "Form section key") + _require_text(self.title, "Form section title") + _validate_text_tuple(self.field_keys, "Form section field keys") + if not self.field_keys: + raise InstitutionalContextError( + "A Form section requires at least one field key." + ) + if len(self.field_keys) != len(set(self.field_keys)): + raise InstitutionalContextError("Form section field keys must be unique.") + + def to_dict(self) -> dict[str, object]: + payload: dict[str, object] = { + "key": self.key, + "title": self.title, + "description": self.description, + "field_keys": list(self.field_keys), + } + if self.visibility_condition is not None: + payload["visibility_condition"] = self.visibility_condition.to_dict() + return payload + + @classmethod + def from_mapping(cls, value: Mapping[str, object]) -> "FormSectionDefinition": + condition = value.get("visibility_condition") + if condition is not None and not isinstance(condition, Mapping): + raise InstitutionalContextError( + "Form section visibility_condition must be an object." + ) + return cls( + key=_required_text(value, "key"), + title=_required_text(value, "title"), + description=_optional_text(value.get("description")), + field_keys=_text_tuple(value.get("field_keys")), + visibility_condition=( + FormConditionExpression.from_mapping(condition) + if isinstance(condition, Mapping) + else None + ), + ) + + +@dataclass(frozen=True, slots=True) +class FormPageDefinition: + key: str + title: str + sections: tuple[FormSectionDefinition, ...] + description: str | None = None + visibility_condition: FormConditionExpression | None = None + + def __post_init__(self) -> None: + _require_identifier(self.key, "Form page key") + _require_text(self.title, "Form page title") + if not self.sections: + raise InstitutionalContextError( + "A Form page requires at least one section." + ) + keys = tuple(item.key for item in self.sections) + if len(keys) != len(set(keys)): + raise InstitutionalContextError( + "Form section keys must be unique within a page." + ) + + def to_dict(self) -> dict[str, object]: + payload: dict[str, object] = { + "key": self.key, + "title": self.title, + "description": self.description, + "sections": [item.to_dict() for item in self.sections], + } + if self.visibility_condition is not None: + payload["visibility_condition"] = self.visibility_condition.to_dict() + return payload + + @classmethod + def from_mapping(cls, value: Mapping[str, object]) -> "FormPageDefinition": + condition = value.get("visibility_condition") + if condition is not None and not isinstance(condition, Mapping): + raise InstitutionalContextError( + "Form page visibility_condition must be an object." + ) + return cls( + key=_required_text(value, "key"), + title=_required_text(value, "title"), + description=_optional_text(value.get("description")), + sections=tuple( + FormSectionDefinition.from_mapping(item) + for item in _mapping_sequence(value.get("sections")) + ), + visibility_condition=( + FormConditionExpression.from_mapping(condition) + if isinstance(condition, Mapping) + else None + ), + ) + + +@dataclass(frozen=True, slots=True) +class FormLocalization: + locale: str + title: str | None = None + description: str | None = None + field_labels: Mapping[str, str] = field(default_factory=dict) + field_help_texts: Mapping[str, str] = field(default_factory=dict) + option_labels: Mapping[str, Mapping[str, str]] = field(default_factory=dict) + page_titles: Mapping[str, str] = field(default_factory=dict) + section_titles: Mapping[str, str] = field(default_factory=dict) + + def __post_init__(self) -> None: + clean_locale = self.locale.strip() + if ( + not clean_locale + or len(clean_locale) > 35 + or not re.fullmatch(r"[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*", clean_locale) + ): + raise InstitutionalContextError("Form localization locale is invalid.") + for label, values in ( + ("field labels", self.field_labels), + ("field help texts", self.field_help_texts), + ("page titles", self.page_titles), + ("section titles", self.section_titles), + ): + if not isinstance(values, Mapping): + raise InstitutionalContextError( + f"Form localization {label} must be an object." + ) + for key, text in values.items(): + _require_identifier(str(key), f"Form localization {label} key") + _require_text(str(text), f"Form localization {label} value") + if not isinstance(self.option_labels, Mapping): + raise InstitutionalContextError( + "Form localization option labels must be an object." + ) + for field_key, labels in self.option_labels.items(): + _require_identifier(str(field_key), "Form option label field key") + if not isinstance(labels, Mapping): + raise InstitutionalContextError( + "Form option labels must map option keys to text." + ) + for option_key, text in labels.items(): + _require_text(str(option_key), "Form option label key") + _require_text(str(text), "Form option label value") + + def to_dict(self) -> dict[str, object]: + return { + "locale": self.locale, + "title": self.title, + "description": self.description, + "field_labels": dict(self.field_labels), + "field_help_texts": dict(self.field_help_texts), + "option_labels": { + key: dict(value) for key, value in self.option_labels.items() + }, + "page_titles": dict(self.page_titles), + "section_titles": dict(self.section_titles), + } + + @classmethod + def from_mapping(cls, value: Mapping[str, object]) -> "FormLocalization": + def text_mapping(key: str) -> dict[str, str]: + raw = value.get(key) + if raw is None: + return {} + if not isinstance(raw, Mapping): + raise InstitutionalContextError( + f"Form localization {key} must be an object." + ) + return {str(item_key): str(item) for item_key, item in raw.items()} + + raw_option_labels = value.get("option_labels") + if raw_option_labels is not None and not isinstance(raw_option_labels, Mapping): + raise InstitutionalContextError( + "Form localization option_labels must be an object." + ) + return cls( + locale=_required_text(value, "locale"), + title=_optional_text(value.get("title")), + description=_optional_text(value.get("description")), + field_labels=text_mapping("field_labels"), + field_help_texts=text_mapping("field_help_texts"), + option_labels={ + str(field_key): { + str(option_key): str(label) for option_key, label in labels.items() + } + for field_key, labels in (raw_option_labels or {}).items() + if isinstance(labels, Mapping) + }, + page_titles=text_mapping("page_titles"), + section_titles=text_mapping("section_titles"), + ) + + @dataclass(frozen=True, slots=True) class FormFieldDefinition: key: str @@ -1869,6 +2101,8 @@ class FormFieldDefinition: options: tuple[str, ...] = () constraints: Mapping[str, object] = field(default_factory=dict) default_value: object | None = None + visibility_condition: FormConditionExpression | None = None + accessibility: Mapping[str, object] = field(default_factory=dict) def __post_init__(self) -> None: _require_identifier(self.key, "Form field key") @@ -1889,12 +2123,14 @@ class FormFieldDefinition: "Only choice form fields may declare options." ) if not isinstance(self.constraints, Mapping): + raise InstitutionalContextError("Form field constraints must be an object.") + if not isinstance(self.accessibility, Mapping): raise InstitutionalContextError( - "Form field constraints must be an object." + "Form field accessibility metadata must be an object." ) def to_dict(self) -> dict[str, object]: - return { + payload: dict[str, object] = { "key": self.key, "label": self.label, "value_type": self.value_type, @@ -1904,13 +2140,26 @@ class FormFieldDefinition: "constraints": dict(self.constraints), "default_value": self.default_value, } + if self.visibility_condition is not None: + payload["visibility_condition"] = self.visibility_condition.to_dict() + if self.accessibility: + payload["accessibility"] = dict(self.accessibility) + return payload @classmethod def from_mapping(cls, value: Mapping[str, object]) -> "FormFieldDefinition": constraints = value.get("constraints") if constraints is not None and not isinstance(constraints, Mapping): + raise InstitutionalContextError("Form field constraints must be an object.") + condition = value.get("visibility_condition") + if condition is not None and not isinstance(condition, Mapping): raise InstitutionalContextError( - "Form field constraints must be an object." + "Form field visibility_condition must be an object." + ) + accessibility = value.get("accessibility") + if accessibility is not None and not isinstance(accessibility, Mapping): + raise InstitutionalContextError( + "Form field accessibility metadata must be an object." ) return cls( key=_required_text(value, "key"), @@ -1921,6 +2170,12 @@ class FormFieldDefinition: options=_text_tuple(value.get("options")), constraints=dict(constraints or {}), default_value=value.get("default_value"), + visibility_condition=( + FormConditionExpression.from_mapping(condition) + if isinstance(condition, Mapping) + else None + ), + accessibility=dict(accessibility or {}), ) @@ -1938,6 +2193,10 @@ class FormDefinition: signature_requirement: FormSignatureRequirement = "none" policy_refs: tuple[str, ...] = () handoff_kinds: tuple[str, ...] = () + pages: tuple[FormPageDefinition, ...] = () + fallback_locale: str | None = None + localizations: tuple[FormLocalization, ...] = () + accessibility: Mapping[str, object] = field(default_factory=dict) metadata: Mapping[str, object] = field(default_factory=dict) def __post_init__(self) -> None: @@ -1967,23 +2226,42 @@ class FormDefinition: ) if not isinstance(self.allow_drafts, bool): raise InstitutionalContextError("Form allow_drafts must be a boolean.") - if not isinstance(self.max_attachments, int) or isinstance( - self.max_attachments, bool - ) or not 0 <= self.max_attachments <= 1000: + if ( + not isinstance(self.max_attachments, int) + or isinstance(self.max_attachments, bool) + or not 0 <= self.max_attachments <= 1000 + ): raise InstitutionalContextError( "Form max_attachments must be between 0 and 1000." ) if self.signature_requirement not in _FORM_SIGNATURE_REQUIREMENTS: - raise InstitutionalContextError( - "Unsupported form signature requirement." - ) + raise InstitutionalContextError("Unsupported form signature requirement.") _validate_text_tuple(self.policy_refs, "Form policy references") _validate_text_tuple(self.handoff_kinds, "Form handoff kinds") + page_keys = tuple(item.key for item in self.pages) + if len(page_keys) != len(set(page_keys)): + raise InstitutionalContextError("Form page keys must be unique.") + locales = tuple(item.locale.casefold() for item in self.localizations) + if len(locales) != len(set(locales)): + raise InstitutionalContextError("Form localization locales must be unique.") + if self.fallback_locale is not None: + if not self.localizations: + raise InstitutionalContextError( + "Form fallback_locale requires at least one localization." + ) + if self.fallback_locale.casefold() not in set(locales): + raise InstitutionalContextError( + "Form fallback_locale must identify a declared localization." + ) + if not isinstance(self.accessibility, Mapping): + raise InstitutionalContextError( + "Form accessibility metadata must be an object." + ) if not isinstance(self.metadata, Mapping): raise InstitutionalContextError("Form metadata must be an object.") def to_dict(self) -> dict[str, object]: - return { + payload: dict[str, object] = { "reference": self.reference.to_dict(disclose_label=True), "key": self.key, "temporal": self.temporal.to_dict(), @@ -1998,19 +2276,30 @@ class FormDefinition: "handoff_kinds": list(self.handoff_kinds), "metadata": dict(self.metadata), } + if self.pages: + payload["pages"] = [item.to_dict() for item in self.pages] + if self.localizations: + payload["localizations"] = [item.to_dict() for item in self.localizations] + payload["fallback_locale"] = self.fallback_locale + if self.accessibility: + payload["accessibility"] = dict(self.accessibility) + return payload @classmethod def from_mapping(cls, value: Mapping[str, object]) -> "FormDefinition": metadata = value.get("metadata") if metadata is not None and not isinstance(metadata, Mapping): raise InstitutionalContextError("Form metadata must be an object.") + accessibility = value.get("accessibility") + if accessibility is not None and not isinstance(accessibility, Mapping): + raise InstitutionalContextError( + "Form accessibility metadata must be an object." + ) raw_max_attachments = value.get("max_attachments", 0) if not isinstance(raw_max_attachments, int) or isinstance( raw_max_attachments, bool ): - raise InstitutionalContextError( - "Form max_attachments must be an integer." - ) + raise InstitutionalContextError("Form max_attachments must be an integer.") return cls( reference=InstitutionalReference.from_mapping( _required_mapping(value, "reference") @@ -2037,6 +2326,16 @@ class FormDefinition: ), policy_refs=_text_tuple(value.get("policy_refs")), handoff_kinds=_text_tuple(value.get("handoff_kinds")), + pages=tuple( + FormPageDefinition.from_mapping(item) + for item in _mapping_sequence(value.get("pages")) + ), + fallback_locale=_optional_text(value.get("fallback_locale")), + localizations=tuple( + FormLocalization.from_mapping(item) + for item in _mapping_sequence(value.get("localizations")) + ), + accessibility=dict(accessibility or {}), metadata=dict(metadata or {}), ) @@ -2112,9 +2411,7 @@ class ServiceLaunchResult: raise InstitutionalContextError( "Service launch result cannot target another tenant." ) - if any( - item.tenant_id != self.service_ref.tenant_id for item in self.evidence - ): + if any(item.tenant_id != self.service_ref.tenant_id for item in self.evidence): raise InstitutionalContextError( "Service launch evidence cannot cross tenants." ) @@ -2316,9 +2613,7 @@ class ProcedureParty: raise InstitutionalContextError( "Party subject belongs to a different tenant." ) - _validate_reference_tenants( - self.reference.tenant_id, (self.procedure_ref,) - ) + _validate_reference_tenants(self.reference.tenant_id, (self.procedure_ref,)) _require_text(self.role, "Procedure party role") if self.status not in _PROCEDURE_PARTY_STATUSES: raise InstitutionalContextError( @@ -2403,9 +2698,7 @@ class ProcedureParty: PartyRepresentation.from_mapping(item) for item in _mapping_sequence(value.get("representations")) ), - contact_snapshot_refs=_text_tuple( - value.get("contact_snapshot_refs") - ), + contact_snapshot_refs=_text_tuple(value.get("contact_snapshot_refs")), evidence=tuple( EvidenceReference.from_mapping(item) for item in _mapping_sequence(value.get("evidence")) @@ -2442,9 +2735,7 @@ def revise_procedure_party( raise InstitutionalContextError( "A procedure party revision requires a new revision identifier." ) - if temporal.recorded_at is None or not str( - temporal.change_reason or "" - ).strip(): + if temporal.recorded_at is None or not str(temporal.change_reason or "").strip(): raise InstitutionalContextError( "A procedure party revision requires recorded_at and change_reason." ) @@ -2512,9 +2803,7 @@ def revoke_party_representation( raise InstitutionalContextError( "A party representation revision requires a new revision identifier." ) - if temporal.recorded_at is None or not str( - temporal.change_reason or "" - ).strip(): + if temporal.recorded_at is None or not str(temporal.change_reason or "").strip(): raise InstitutionalContextError( "A party representation revision requires recorded_at and change_reason." ) @@ -2646,9 +2935,10 @@ class FormalDecision: raise InstitutionalContextError( "Formal decisions require fact evidence and legal bases." ) - if self.state in {"decided", "effective", "corrected"} and not str( - self.operative_result or "" - ).strip(): + if ( + self.state in {"decided", "effective", "corrected"} + and not str(self.operative_result or "").strip() + ): raise InstitutionalContextError( "A decided formal outcome requires an operative result." ) @@ -2714,9 +3004,7 @@ class FormalDecision: for item in self.legal_bases ], "assurance_level": self.assurance_level, - "automation_preparation_refs": list( - self.automation_preparation_refs - ), + "automation_preparation_refs": list(self.automation_preparation_refs), "operative_result": self.operative_result if include_protected else None, "reasoning": self.reasoning if include_protected else None, "conditions": list(self.conditions) if include_protected else [], @@ -2820,9 +3108,7 @@ def revise_formal_decision( raise InstitutionalContextError( "A formal decision revision requires a new revision identifier." ) - if temporal.recorded_at is None or not str( - temporal.change_reason or "" - ).strip(): + if temporal.recorded_at is None or not str(temporal.change_reason or "").strip(): raise InstitutionalContextError( "A formal decision revision requires recorded_at and change_reason." ) @@ -2852,9 +3138,7 @@ def revise_formal_decision( fact_evidence=current.fact_evidence, legal_bases=current.legal_bases, assurance_level=( - assurance_level - if assurance_level is not None - else current.assurance_level + assurance_level if assurance_level is not None else current.assurance_level ), automation_preparation_refs=( tuple(automation_preparation_refs) @@ -2881,9 +3165,7 @@ def revise_formal_decision( else current.observed_effects ), delivery_refs=( - tuple(delivery_refs) - if delivery_refs is not None - else current.delivery_refs + tuple(delivery_refs) if delivery_refs is not None else current.delivery_refs ), publication_refs=( tuple(publication_refs) @@ -2891,24 +3173,16 @@ def revise_formal_decision( else current.publication_refs ), remedy_refs=( - tuple(remedy_refs) - if remedy_refs is not None - else current.remedy_refs + tuple(remedy_refs) if remedy_refs is not None else current.remedy_refs ), review_refs=( - tuple(review_refs) - if review_refs is not None - else current.review_refs + tuple(review_refs) if review_refs is not None else current.review_refs ), correction_of_ref=( - previous_reference - if state == "corrected" - else current.correction_of_ref + previous_reference if state == "corrected" else current.correction_of_ref ), revocation_of_ref=( - previous_reference - if state == "revoked" - else current.revocation_of_ref + previous_reference if state == "revoked" else current.revocation_of_ref ), supersedes_ref=previous_reference, ) @@ -3077,9 +3351,7 @@ def _required_mapping( def _required_datetime(value: object | None) -> datetime: result = _optional_datetime(value) if result is None: - raise InstitutionalContextError( - "Institutional payload datetime is required." - ) + raise InstitutionalContextError("Institutional payload datetime is required.") return result @@ -3137,13 +3409,9 @@ def _validate_inspection_url(value: str | None) -> None: return parsed = urlsplit(value) if parsed.scheme not in {"http", "https"} or not parsed.hostname: - raise InstitutionalContextError( - "Inspection URLs must use HTTP or HTTPS." - ) + raise InstitutionalContextError("Inspection URLs must use HTTP or HTTPS.") if parsed.username is not None or parsed.password is not None: - raise InstitutionalContextError( - "Inspection URLs must not contain credentials." - ) + raise InstitutionalContextError("Inspection URLs must not contain credentials.") def _validate_service_launch_href(value: str | None) -> None: @@ -3230,9 +3498,7 @@ def _external_reference_from_mapping( system=_required_text(value, "system"), object_type=_required_text(value, "object_type"), object_id=_required_text(value, "object_id"), - maturity=cast( - IntegrationMaturity, str(value.get("maturity") or "link") - ), + maturity=cast(IntegrationMaturity, str(value.get("maturity") or "link")), authority_mode=cast( SourceAuthorityMode, str(value.get("authority_mode") or "linked_reference"), @@ -3264,10 +3530,16 @@ __all__ = [ "DecisionRegistry", "EvidenceReference", "ExternalSourceReference", + "FormConditionExpression", + "FormConditionKind", + "FormConditionOperator", "FormDefinition", "FormDefinitionProvider", "FormFieldDefinition", + "FormLocalization", + "FormPageDefinition", "FormPublicationState", + "FormSectionDefinition", "FormSignatureRequirement", "FormValueType", "FormalDecision", diff --git a/src/govoplan_core/core/voting.py b/src/govoplan_core/core/voting.py new file mode 100644 index 0000000..ebdf1e4 --- /dev/null +++ b/src/govoplan_core/core/voting.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from datetime import datetime +from typing import Protocol, runtime_checkable + + +CAPABILITY_VOTING_BALLOTS = "voting.ballots" +CAPABILITY_VOTING_PROVIDER_PREFIX = "voting.provider." + +VOTING_ASSURANCE_RECORDED = "recorded" +VOTING_ASSURANCE_CONFIDENTIAL = "confidential" +VOTING_ASSURANCE_SECRET = "secret" +VOTING_ASSURANCE_EXTERNAL_CERTIFIED = "external_certified" + + +class VotingCapabilityError(ValueError): + """Stable error raised by Voting capability implementations.""" + + +def voting_provider_capability(provider_id: str) -> str: + normalized = str(provider_id or "").strip().lower() + if not normalized or any( + character not in "abcdefghijklmnopqrstuvwxyz0123456789_.-" + for character in normalized + ): + raise VotingCapabilityError("Voting provider id is invalid.") + return f"{CAPABILITY_VOTING_PROVIDER_PREFIX}{normalized}" + + +@dataclass(frozen=True, slots=True) +class VotingOption: + key: str + label: str + description: str | None = None + + +@dataclass(frozen=True, slots=True) +class VotingElector: + subject_id: str + label: str | None = None + weight: int = 1 + provenance: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class VotingBallotCreateCommand: + title: str + method: str + assurance_profile: str + options: tuple[VotingOption, ...] + electorate: tuple[VotingElector, ...] + description: str | None = None + context_module: str | None = None + context_resource_type: str | None = None + context_resource_id: str | None = None + quorum_weight: int = 0 + threshold_numerator: int = 1 + threshold_denominator: int = 2 + allow_replacement: bool = True + opens_at: datetime | None = None + closes_at: datetime | None = None + provider_id: str | None = None + provider_ballot_ref: str | None = None + metadata: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class VotingCastCommand: + selections: tuple[str, ...] + idempotency_key: str + elector_id: str | None = None + + +@dataclass(frozen=True, slots=True) +class VotingBallotRef: + id: str + revision: int + state: str + assurance_profile: str + definition_sha256: str | None = None + electorate_sha256: str | None = None + + +@dataclass(frozen=True, slots=True) +class VotingReceipt: + ballot_id: str + revision: int + receipt_sha256: str + cast_at: datetime + replaced_previous: bool = False + replayed: bool = False + + +@dataclass(frozen=True, slots=True) +class VotingResult: + ballot_id: str + revision: int + counts: Mapping[str, int] + weighted_counts: Mapping[str, int] + cast_count: int + cast_weight: int + eligible_count: int + eligible_weight: int + quorum_met: bool + threshold_met: bool + winning_options: tuple[str, ...] + result_sha256: str + evidence: tuple[Mapping[str, object], ...] = () + + +@dataclass(frozen=True, slots=True) +class ExternalVotingFinalizationRequest: + tenant_id: str + ballot_id: str + provider_ballot_ref: str + definition_sha256: str + electorate_sha256: str + options: tuple[VotingOption, ...] + eligible_count: int + eligible_weight: int + requested_at: datetime + idempotency_key: str + + +@runtime_checkable +class ExternalVotingProvider(Protocol): + """Provider boundary for confidential, secret, or certified voting. + + Providers return aggregate results and evidence only. Raw ballots and + provider credentials must not cross this boundary. + """ + + def finalize_ballot( + self, + session: object, + principal: object, + *, + request: ExternalVotingFinalizationRequest, + ) -> VotingResult: ... + + +@runtime_checkable +class VotingBallotProvider(Protocol): + def create_ballot( + self, + session: object, + principal: object, + *, + command: VotingBallotCreateCommand, + idempotency_key: str, + ) -> VotingBallotRef: ... + + def get_ballot( + self, + session: object, + principal: object, + *, + ballot_id: str, + ) -> Mapping[str, object] | None: ... + + def open_ballot( + self, + session: object, + principal: object, + *, + ballot_id: str, + expected_revision: int, + idempotency_key: str, + ) -> VotingBallotRef: ... + + def cast_ballot( + self, + session: object, + principal: object, + *, + ballot_id: str, + command: VotingCastCommand, + ) -> VotingReceipt: ... + + def close_ballot( + self, + session: object, + principal: object, + *, + ballot_id: str, + expected_revision: int, + idempotency_key: str, + ) -> VotingResult: ... + + def certify_ballot( + self, + session: object, + principal: object, + *, + ballot_id: str, + expected_revision: int, + evidence: Sequence[Mapping[str, object]], + idempotency_key: str, + ) -> VotingBallotRef: ... + + +__all__ = [ + "CAPABILITY_VOTING_BALLOTS", + "CAPABILITY_VOTING_PROVIDER_PREFIX", + "ExternalVotingFinalizationRequest", + "ExternalVotingProvider", + "VOTING_ASSURANCE_CONFIDENTIAL", + "VOTING_ASSURANCE_EXTERNAL_CERTIFIED", + "VOTING_ASSURANCE_RECORDED", + "VOTING_ASSURANCE_SECRET", + "VotingBallotCreateCommand", + "VotingBallotProvider", + "VotingBallotRef", + "VotingCapabilityError", + "VotingCastCommand", + "VotingElector", + "VotingOption", + "VotingReceipt", + "VotingResult", + "voting_provider_capability", +] diff --git a/src/govoplan_core/core/workflows.py b/src/govoplan_core/core/workflows.py index 5aa75f6..5635f5b 100644 --- a/src/govoplan_core/core/workflows.py +++ b/src/govoplan_core/core/workflows.py @@ -7,10 +7,13 @@ import hashlib import json from typing import Protocol, runtime_checkable +from govoplan_core.core.events import PlatformEvent + CAPABILITY_WORKFLOW_RUNTIME_WORKER = "workflow.runtimeWorker" CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS = "workflow.definitionContributions" CAPABILITY_WORKFLOW_ORCHESTRATION = "workflow.orchestration" +CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER = "workflow.triggerDispatcher" @dataclass(frozen=True, slots=True) @@ -126,8 +129,27 @@ class WorkflowRuntimeWorker(Protocol): *, now: datetime | None = None, limit: int = 50, - ) -> Mapping[str, object]: - ... + ) -> Mapping[str, object]: ... + + +@runtime_checkable +class WorkflowTriggerDispatcher(Protocol): + """Durable schedule/event start and wait-resumption boundary.""" + + def dispatch_due( + self, + session: object, + *, + now: datetime | None = None, + limit: int = 50, + ) -> Mapping[str, object]: ... + + def ingest_event( + self, + session: object, + *, + event: PlatformEvent, + ) -> Mapping[str, object]: ... @runtime_checkable @@ -137,8 +159,7 @@ class WorkflowDefinitionContributionProvider(Protocol): session: object, *, tenant_ids: Sequence[str] = (), - ) -> Mapping[str, object]: - ... + ) -> Mapping[str, object]: ... @runtime_checkable @@ -180,11 +201,20 @@ def workflow_runtime_worker( ): return None capability = registry.capability(CAPABILITY_WORKFLOW_RUNTIME_WORKER) - return ( - capability - if isinstance(capability, WorkflowRuntimeWorker) - else None - ) + return capability if isinstance(capability, WorkflowRuntimeWorker) else None + + +def workflow_trigger_dispatcher( + registry: object | None, +) -> WorkflowTriggerDispatcher | None: + if ( + registry is None + or not hasattr(registry, "has_capability") + or not registry.has_capability(CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER) + ): + return None + capability = registry.capability(CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER) + return capability if isinstance(capability, WorkflowTriggerDispatcher) else None def workflow_definition_contribution_provider( @@ -193,14 +223,10 @@ def workflow_definition_contribution_provider( if ( registry is None or not hasattr(registry, "has_capability") - or not registry.has_capability( - CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS - ) + or not registry.has_capability(CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS) ): return None - capability = registry.capability( - CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS - ) + capability = registry.capability(CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS) return ( capability if isinstance(capability, WorkflowDefinitionContributionProvider) @@ -225,15 +251,18 @@ __all__ = [ "CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS", "CAPABILITY_WORKFLOW_ORCHESTRATION", "CAPABILITY_WORKFLOW_RUNTIME_WORKER", + "CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER", "WorkflowCurrentStepResolution", "WorkflowDefinitionContribution", "WorkflowDefinitionContributionProvider", "WorkflowInstanceRef", "WorkflowOrchestrationProvider", "WorkflowRuntimeWorker", + "WorkflowTriggerDispatcher", "WorkflowStandardStartRequest", "workflow_definition_contribution_hash", "workflow_definition_contribution_provider", "workflow_orchestration_provider", "workflow_runtime_worker", + "workflow_trigger_dispatcher", ] diff --git a/tests/test_encryption_contract.py b/tests/test_encryption_contract.py new file mode 100644 index 0000000..d3cf771 --- /dev/null +++ b/tests/test_encryption_contract.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from datetime import UTC, datetime +import unittest + +from govoplan_core.core.encryption import ( + CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX, + ContentProtectionEnvelope, + KeyMaterialDescriptor, + KeyVaultCreateRequest, + key_material_provider, +) + + +NOW = datetime(2026, 8, 1, 12, 0, tzinfo=UTC) + + +class Provider: + def provision_key(self, _session, _principal, *, request): + return KeyMaterialDescriptor( + provider_id="test", + provider_key_ref=f"test:{request.vault_id}:{request.key_version}", + algorithm_suite=request.algorithm_suite, + state="active", + created_at=NOW, + ) + + def revoke_key(self, _session, _principal, **_kwargs): + raise NotImplementedError + + def schedule_key_destruction(self, _session, _principal, **_kwargs): + raise NotImplementedError + + def key_status(self, _session, **_kwargs): + return None + + +class Registry: + def __init__(self, values): + self.values = values + + def has_capability(self, name: str) -> bool: + return name in self.values + + def capability(self, name: str): + return self.values[name] + + +class EncryptionContractTests(unittest.TestCase): + def test_provider_resolution_is_optional_and_structural(self) -> None: + name = f"{CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX}test" + provider = Provider() + self.assertIs( + provider, key_material_provider(Registry({name: provider}), "test") + ) + self.assertIsNone(key_material_provider(Registry({}), "test")) + + def test_secret_bearing_policy_and_envelope_metadata_are_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "secret material"): + KeyVaultCreateRequest( + tenant_id="tenant-1", + vault_id="vault-1", + name="Vault", + provider_id="test", + purpose="content", + algorithm_suite="AES-256-GCM", + scope_type="tenant", + scope_id="tenant-1", + policy_ref="policy:1", + idempotency_key="create-1", + provider_policy={"password": "forbidden"}, + ) + with self.assertRaisesRegex(ValueError, "secret material"): + ContentProtectionEnvelope( + envelope_id="envelope-1", + tenant_id="tenant-1", + owner_module="files", + resource_type="file", + resource_id="file-1", + profile_kind="server_envelope", + profile_id="profile-1", + provider_id="test", + vault_id="vault-1", + key_version=1, + algorithm_suite="AES-256-GCM", + ciphertext_ref="object://ciphertext/1", + ciphertext_digest="sha256:ciphertext", + authenticated_context_digest="sha256:context", + state="active", + created_at=NOW, + metadata={"private_key": "forbidden"}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_module_system.py b/tests/test_module_system.py index e150853..9d5c3ec 100644 --- a/tests/test_module_system.py +++ b/tests/test_module_system.py @@ -232,7 +232,7 @@ class ModuleSystemTests(unittest.TestCase): self.assertTrue(manifests["campaigns"].required_capabilities) self.assertEqual( manifests["campaigns"].optional_dependencies, - ("files", "mail", "notifications", "addresses", "postbox"), + ("files", "mail", "notifications", "addresses", "postbox", "approvals"), ) self.assertEqual(manifests["dashboard"].dependencies, ()) self.assertTrue(manifests["dashboard"].required_capabilities) @@ -3433,7 +3433,7 @@ finally: "version_max_exclusive": "0.3.0", }, modules["campaigns"]["requires_interfaces"]) self.assertEqual( - ["files", "mail", "notifications", "addresses", "postbox"], + ["files", "mail", "notifications", "addresses", "postbox", "approvals"], modules["campaigns"]["optional_dependencies"], ) self.assertEqual("requires_review", modules["files"]["migration_safety"]) diff --git a/tests/test_platform_event_worker.py b/tests/test_platform_event_worker.py index 1ed9beb..d73dd2d 100644 --- a/tests/test_platform_event_worker.py +++ b/tests/test_platform_event_worker.py @@ -42,6 +42,10 @@ class PlatformEventWorkerTests(unittest.TestCase): "govoplan_core.celery_app._dataflow_trigger_dispatcher", return_value=dataflow, ), + patch( + "govoplan_core.celery_app._workflow_trigger_dispatcher", + return_value=None, + ), patch( "govoplan_core.db.session.get_database", return_value=database, @@ -109,21 +113,15 @@ class PlatformEventWorkerTests(unittest.TestCase): def test_worker_routes_and_periodic_tasks_are_registered(self) -> None: self.assertEqual( {"queue": "events"}, - celery.conf.task_routes[ - "govoplan.events.dispatch_outbox" - ], + celery.conf.task_routes["govoplan.events.dispatch_outbox"], ) self.assertEqual( {"queue": "events"}, - celery.conf.task_routes[ - "govoplan.events.purge_outbox" - ], + celery.conf.task_routes["govoplan.events.purge_outbox"], ) self.assertEqual( "govoplan.events.purge_outbox", - celery.conf.beat_schedule[ - "platform-event-retention-daily" - ]["task"], + celery.conf.beat_schedule["platform-event-retention-daily"]["task"], ) diff --git a/tests/test_workflow_runtime_worker.py b/tests/test_workflow_runtime_worker.py index 237a816..70c3ab0 100644 --- a/tests/test_workflow_runtime_worker.py +++ b/tests/test_workflow_runtime_worker.py @@ -12,12 +12,15 @@ from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.workflows import ( CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS, CAPABILITY_WORKFLOW_RUNTIME_WORKER, + CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER, WorkflowDefinitionContribution, WorkflowDefinitionContributionProvider, WorkflowRuntimeWorker, + WorkflowTriggerDispatcher, workflow_definition_contribution_hash, workflow_definition_contribution_provider, workflow_runtime_worker, + workflow_trigger_dispatcher, ) @@ -31,6 +34,14 @@ class _ContributionProvider: return {"created": 0, "tenant_ids": tuple(tenant_ids)} +class _TriggerDispatcher: + def dispatch_due(self, session, *, now=None, limit=50): + return {"selected": 0} + + def ingest_event(self, session, *, event): + return {"queued": 0} + + class WorkflowRuntimeWorkerTests(unittest.TestCase): def test_contribution_contract_hash_and_provider_are_stable(self) -> None: contribution = WorkflowDefinitionContribution( @@ -77,9 +88,7 @@ class WorkflowRuntimeWorkerTests(unittest.TestCase): name="Workflow runtime test", version="test", capability_factories={ - CAPABILITY_WORKFLOW_RUNTIME_WORKER: ( - lambda _context: worker - ), + CAPABILITY_WORKFLOW_RUNTIME_WORKER: (lambda _context: worker), }, ) ) @@ -90,6 +99,29 @@ class WorkflowRuntimeWorkerTests(unittest.TestCase): self.assertIs(worker, workflow_runtime_worker(registry)) self.assertIsNone(workflow_runtime_worker(PlatformRegistry())) + def test_trigger_dispatch_contract_is_runtime_checkable_and_resolved(self) -> None: + dispatcher = _TriggerDispatcher() + self.assertIsInstance(dispatcher, WorkflowTriggerDispatcher) + registry = PlatformRegistry() + registry.register( + ModuleManifest( + id="workflow_trigger_test", + name="Workflow trigger test", + version="test", + capability_factories={ + CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER: ( + lambda _context: dispatcher + ), + }, + ) + ) + registry.configure_capability_context( + ModuleContext(registry=registry, settings=object()) + ) + + self.assertIs(dispatcher, workflow_trigger_dispatcher(registry)) + self.assertIsNone(workflow_trigger_dispatcher(PlatformRegistry())) + def test_celery_task_commits_reconciliation(self) -> None: session = MagicMock() database = MagicMock() @@ -121,9 +153,7 @@ class WorkflowRuntimeWorkerTests(unittest.TestCase): {"queue": "workflow"}, celery.conf.task_routes["govoplan.workflow.reconcile"], ) - schedule = celery.conf.beat_schedule[ - "workflow-reconcile-every-five-seconds" - ] + schedule = celery.conf.beat_schedule["workflow-reconcile-every-five-seconds"] self.assertEqual("govoplan.workflow.reconcile", schedule["task"]) self.assertEqual(5.0, schedule["schedule"]) diff --git a/webui/package-lock.json b/webui/package-lock.json index c7f3e1c..2e86322 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -11,6 +11,7 @@ "@govoplan/access-webui": "file:../../govoplan-access/webui", "@govoplan/addresses-webui": "file:../../govoplan-addresses/webui", "@govoplan/admin-webui": "file:../../govoplan-admin/webui", + "@govoplan/approvals-webui": "file:../../govoplan-approvals/webui", "@govoplan/audit-webui": "file:../../govoplan-audit/webui", "@govoplan/calendar-webui": "file:../../govoplan-calendar/webui", "@govoplan/campaign-webui": "file:../../govoplan-campaign/webui", @@ -39,6 +40,7 @@ "@govoplan/search-webui": "file:../../govoplan-search/webui", "@govoplan/tenancy-webui": "file:../../govoplan-tenancy/webui", "@govoplan/views-webui": "file:../../govoplan-views/webui", + "@govoplan/voting-webui": "file:../../govoplan-voting/webui", "@govoplan/workflow-webui": "file:../../govoplan-workflow/webui", "@tiptap/core": "^3.29.2", "@tiptap/extension-image": "^3.29.2", @@ -120,6 +122,21 @@ } } }, + "../../govoplan-approvals/webui": { + "name": "@govoplan/approvals-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-audit/webui": { "name": "@govoplan/audit-webui", "version": "0.1.8", @@ -603,6 +620,21 @@ } } }, + "../../govoplan-voting/webui": { + "name": "@govoplan/voting-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-workflow/webui": { "name": "@govoplan/workflow-webui", "version": "0.1.14", @@ -1385,6 +1417,10 @@ "resolved": "../../govoplan-admin/webui", "link": true }, + "node_modules/@govoplan/approvals-webui": { + "resolved": "../../govoplan-approvals/webui", + "link": true + }, "node_modules/@govoplan/audit-webui": { "resolved": "../../govoplan-audit/webui", "link": true @@ -1497,6 +1533,10 @@ "resolved": "../../govoplan-views/webui", "link": true }, + "node_modules/@govoplan/voting-webui": { + "resolved": "../../govoplan-voting/webui", + "link": true + }, "node_modules/@govoplan/workflow-webui": { "resolved": "../../govoplan-workflow/webui", "link": true diff --git a/webui/package.json b/webui/package.json index 0e1db4e..f99d95b 100644 --- a/webui/package.json +++ b/webui/package.json @@ -49,6 +49,7 @@ }, "dependencies": { "@govoplan/access-webui": "file:../../govoplan-access/webui", + "@govoplan/approvals-webui": "file:../../govoplan-approvals/webui", "@govoplan/addresses-webui": "file:../../govoplan-addresses/webui", "@govoplan/admin-webui": "file:../../govoplan-admin/webui", "@govoplan/audit-webui": "file:../../govoplan-audit/webui", @@ -79,6 +80,7 @@ "@govoplan/search-webui": "file:../../govoplan-search/webui", "@govoplan/tenancy-webui": "file:../../govoplan-tenancy/webui", "@govoplan/views-webui": "file:../../govoplan-views/webui", + "@govoplan/voting-webui": "file:../../govoplan-voting/webui", "@govoplan/workflow-webui": "file:../../govoplan-workflow/webui", "@tiptap/core": "^3.29.2", "@tiptap/extension-image": "^3.29.2", diff --git a/webui/scripts/test-module-permutations.mjs b/webui/scripts/test-module-permutations.mjs index 9994322..2a22476 100644 --- a/webui/scripts/test-module-permutations.mjs +++ b/webui/scripts/test-module-permutations.mjs @@ -3,6 +3,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; const packageByModule = { access: "@govoplan/access-webui", + approvals: "@govoplan/approvals-webui", admin: "@govoplan/admin-webui", addresses: "@govoplan/addresses-webui", audit: "@govoplan/audit-webui", @@ -33,6 +34,7 @@ const packageByModule = { search: "@govoplan/search-webui", tenancy: "@govoplan/tenancy-webui", views: "@govoplan/views-webui", + voting: "@govoplan/voting-webui", workflow: "@govoplan/workflow-webui" }; @@ -78,7 +80,9 @@ 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", "cases", "committee", "campaigns", "files", "forms", "forms_runtime", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "portal", "postbox", "projects", "reporting", "risk_compliance", "search"] } + { name: "approvals-only", modules: ["access", "approvals"] }, + { name: "voting-only", modules: ["access", "voting"] }, + { name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "approvals", "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", "voting"] } ]; const npmExec = process.env.npm_execpath; diff --git a/webui/src/platform/modules.ts b/webui/src/platform/modules.ts index fed47ca..8792906 100644 --- a/webui/src/platform/modules.ts +++ b/webui/src/platform/modules.ts @@ -1,4 +1,4 @@ -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 { Activity, Bell, BookUser, BriefcaseBusiness, Building2, CalendarClock, CalendarDays, ClipboardPenLine, DatabaseZap, Folder, FolderKanban, Form, Gavel, Inbox, Landmark, LayoutDashboard, LayoutTemplate, ListChecks, ListTree, Mail, Mails, RadioTower, Shield, ShieldCheck, Users, Vote, 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 { @@ -75,6 +75,7 @@ const iconByName: Record = { landmark: Landmark, "layout-template": LayoutTemplate, "list-tree": ListTree, + "list-checks": ListChecks, mail: Mail, notifications: Bell, organizations: Building2, @@ -84,6 +85,7 @@ const iconByName: Record = { "shield-check": ShieldCheck, templates: LayoutTemplate, users: Users, + vote: Vote, waypoints: Waypoints, workflow: WorkflowIcon }; diff --git a/webui/vite.config.ts b/webui/vite.config.ts index 564435f..6111ba9 100644 --- a/webui/vite.config.ts +++ b/webui/vite.config.ts @@ -14,6 +14,7 @@ const resolvedInstalledModuleVirtualPrefix = `\0${installedModuleVirtualPrefix}` const defaultWebModulePackages = [ "@govoplan/access-webui", + "@govoplan/approvals-webui", "@govoplan/admin-webui", "@govoplan/addresses-webui", "@govoplan/audit-webui", @@ -43,6 +44,7 @@ const defaultWebModulePackages = [ "@govoplan/search-webui", "@govoplan/tenancy-webui", "@govoplan/views-webui", + "@govoplan/voting-webui", "@govoplan/workflow-webui" ]; @@ -224,6 +226,7 @@ export default defineConfig({ allow: [ fileURLToPath(new URL('.', import.meta.url)), fileURLToPath(new URL('../../govoplan-access/webui', import.meta.url)), + fileURLToPath(new URL('../../govoplan-approvals/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-admin/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-addresses/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-audit/webui', import.meta.url)), @@ -250,6 +253,7 @@ export default defineConfig({ fileURLToPath(new URL('../../govoplan-search/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-tenancy/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-views/webui', import.meta.url)), + fileURLToPath(new URL('../../govoplan-voting/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-workflow/webui', import.meta.url)) ] },