Add governed institutional capability contracts

This commit is contained in:
2026-08-01 20:57:25 +02:00
parent 7192d32e65
commit 2b4eb0151f
16 changed files with 2203 additions and 249 deletions
+38 -4
View File
@@ -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,
)
+207
View File
@@ -0,0 +1,207 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
CAPABILITY_APPROVAL_REQUESTS = "approvals.requests"
class ApprovalCapabilityError(ValueError):
"""Stable error raised by Approval capability implementations."""
@dataclass(frozen=True, slots=True)
class ApprovalActorSelector:
kind: str
value: str
label: str | None = None
@dataclass(frozen=True, slots=True)
class ApprovalStepDefinition:
key: str
label: str
selectors: tuple[ApprovalActorSelector, ...]
required_approvals: int = 1
rejection_policy: str = "fail_fast"
due_at: datetime | None = None
signature_required: bool = False
forbidden_evidence_roles: tuple[str, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ApprovalRequestCreateCommand:
title: str
subject_module: str
subject_type: str
subject_id: str
subject_version: str | None
subject_digest: str
steps: tuple[ApprovalStepDefinition, ...]
description: str | None = None
separation_of_duties: bool = True
unique_actors_across_steps: bool = False
expires_at: datetime | None = None
policy_refs: tuple[str, ...] = ()
evidence_actors: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
template_id: str | None = None
template_revision: int | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ApprovalTemplateCreateCommand:
key: str
title: str
steps: tuple[ApprovalStepDefinition, ...]
description: str | None = None
separation_of_duties: bool = True
unique_actors_across_steps: bool = False
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ApprovalTemplateRef:
id: str
key: str
revision: int
state: str
content_sha256: str
@dataclass(frozen=True, slots=True)
class ApprovalDecisionCommand:
outcome: str
reason: str
expected_revision: int
idempotency_key: str
delegated_for_account_id: str | None = None
signature_ref: Mapping[str, object] | None = None
@dataclass(frozen=True, slots=True)
class ApprovalRequestRef:
id: str
revision: int
state: str
current_step_key: str | None = None
@dataclass(frozen=True, slots=True)
class ApprovalDecisionReceipt:
request_id: str
revision: int
state: str
step_key: str
outcome: str
actor_id: str
recorded_at: datetime
receipt_sha256: str
authority_provenance: Mapping[str, object] = field(default_factory=dict)
replayed: bool = False
@dataclass(frozen=True, slots=True)
class ApprovalCheck:
request_id: str
revision: int
state: str
approved: bool
subject_module: str
subject_type: str
subject_id: str
subject_version: str | None
subject_digest: str
completed_at: datetime | None = None
@runtime_checkable
class ApprovalRequestProvider(Protocol):
def create_template(
self,
session: object,
principal: object,
*,
command: ApprovalTemplateCreateCommand,
idempotency_key: str,
) -> ApprovalTemplateRef: ...
def revise_template(
self,
session: object,
principal: object,
*,
template_id: str,
command: ApprovalTemplateCreateCommand,
expected_revision: int,
idempotency_key: str,
) -> ApprovalTemplateRef: ...
def publish_template(
self,
session: object,
principal: object,
*,
template_id: str,
expected_revision: int,
idempotency_key: str,
) -> ApprovalTemplateRef: ...
def create_request(
self,
session: object,
principal: object,
*,
command: ApprovalRequestCreateCommand,
idempotency_key: str,
) -> ApprovalRequestRef: ...
def get_request(
self,
session: object,
principal: object,
*,
request_id: str,
) -> Mapping[str, object] | None: ...
def decide(
self,
session: object,
principal: object,
*,
request_id: str,
command: ApprovalDecisionCommand,
) -> ApprovalDecisionReceipt: ...
def check_approved(
self,
session: object,
principal: object,
*,
request_id: str,
subject_module: str,
subject_type: str,
subject_id: str,
subject_version: str | None,
subject_digest: str,
) -> ApprovalCheck: ...
__all__ = [
"ApprovalActorSelector",
"ApprovalCapabilityError",
"ApprovalCheck",
"ApprovalDecisionCommand",
"ApprovalDecisionReceipt",
"ApprovalRequestCreateCommand",
"ApprovalRequestProvider",
"ApprovalRequestRef",
"ApprovalStepDefinition",
"ApprovalTemplateCreateCommand",
"ApprovalTemplateRef",
"CAPABILITY_APPROVAL_REQUESTS",
]
+692
View File
@@ -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",
]
+321
View File
@@ -0,0 +1,321 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
CAPABILITY_IDENTITY_TRUST_DIRECTORY = "identity_trust.directory"
CAPABILITY_IDENTITY_TRUST_ASSURANCE = "identity_trust.assurance"
IDENTITY_TRUST_CONTRACT_VERSION = "1"
DeviceKeyPurpose = Literal["encryption", "signing", "encryption_and_signing"]
DeviceKeyStatus = Literal["active", "revoked", "expired"]
TrustSubjectKind = Literal[
"identity",
"account",
"function",
"postbox",
"external_recipient",
]
_PRIVATE_JWK_FIELDS = frozenset({"d", "p", "q", "dp", "dq", "qi", "oth", "k"})
@dataclass(frozen=True, slots=True)
class DeviceKeyRegistration:
tenant_id: str
identity_id: str
account_id: str
device_id: str
key_id: str
algorithm: str
public_jwk: Mapping[str, object]
purpose: DeviceKeyPurpose = "encryption"
assurance_level: str = "software"
attestation_ref: str | None = None
expires_at: datetime | None = None
idempotency_key: str = ""
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract(self.contract_version)
for label, value in (
("tenant id", self.tenant_id),
("identity id", self.identity_id),
("account id", self.account_id),
("device id", self.device_id),
("key id", self.key_id),
("algorithm", self.algorithm),
("idempotency key", self.idempotency_key),
):
_require_text(value, label)
if not self.public_jwk or _PRIVATE_JWK_FIELDS & set(self.public_jwk):
raise ValueError("Only a bounded public JWK may be registered")
@dataclass(frozen=True, slots=True)
class DeviceKeyRef:
tenant_id: str
identity_id: str
account_id: str
device_id: str
key_id: str
algorithm: str
public_jwk: Mapping[str, object]
purpose: DeviceKeyPurpose
assurance_level: str
status: DeviceKeyStatus
epoch: int
registered_at: datetime
attestation_ref: str | None = None
expires_at: datetime | None = None
revoked_at: datetime | None = None
revocation_reason: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
@dataclass(frozen=True, slots=True)
class KeyEpochRotationRequest:
tenant_id: str
subject_kind: TrustSubjectKind
subject_id: str
reason: str
access_decision_ref: str
idempotency_key: str
history_policy: str = "all_retained"
previous_epoch: int | None = None
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract(self.contract_version)
for label, value in (
("tenant id", self.tenant_id),
("subject id", self.subject_id),
("reason", self.reason),
("access decision reference", self.access_decision_ref),
("idempotency key", self.idempotency_key),
):
_require_text(value, label)
@dataclass(frozen=True, slots=True)
class KeyEpochRef:
tenant_id: str
subject_kind: TrustSubjectKind
subject_id: str
epoch: int
state: Literal["active", "superseded", "revoked"]
history_policy: str
effective_at: datetime
previous_epoch: int | None = None
reason: str | None = None
access_decision_ref: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
@dataclass(frozen=True, slots=True)
class KeyAccessRequest:
tenant_id: str
account_id: str
device_key_id: str
subject_kind: TrustSubjectKind
subject_id: str
key_epoch: int
access_decision_ref: str
purpose: str
requested_at: datetime
function_assignment_id: str | None = None
delegation_id: str | None = None
resource_ref: str | None = None
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract(self.contract_version)
if self.key_epoch < 1:
raise ValueError("Key epoch must be positive")
for label, value in (
("tenant id", self.tenant_id),
("account id", self.account_id),
("device key id", self.device_key_id),
("subject id", self.subject_id),
("access decision reference", self.access_decision_ref),
("purpose", self.purpose),
):
_require_text(value, label)
@dataclass(frozen=True, slots=True)
class KeyAccessDecision:
allowed: bool
decision_ref: str
reason: str
device_key: DeviceKeyRef | None = None
epoch: KeyEpochRef | None = None
audit_event_ref: str | None = None
requirements: tuple[str, ...] = ()
provenance: Mapping[str, object] = field(default_factory=dict)
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
@dataclass(frozen=True, slots=True)
class AssuranceCheckRequest:
tenant_id: str
account_id: str
purpose: str
minimum_level: str
evidence_ref: str
evaluated_at: datetime
maximum_age_seconds: int = 300
device_key_id: str | None = None
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract(self.contract_version)
if self.maximum_age_seconds < 1:
raise ValueError("Assurance maximum age must be positive")
for label, value in (
("tenant id", self.tenant_id),
("account id", self.account_id),
("purpose", self.purpose),
("minimum level", self.minimum_level),
("evidence reference", self.evidence_ref),
):
_require_text(value, label)
@dataclass(frozen=True, slots=True)
class AssuranceDecision:
allowed: bool
reason: str
assurance_level: str | None = None
evidence_ref: str | None = None
verified_at: datetime | None = None
expires_at: datetime | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
@runtime_checkable
class IdentityTrustDirectory(Protocol):
def register_device_key(
self,
session: object,
principal: object,
*,
request: DeviceKeyRegistration,
) -> DeviceKeyRef: ...
def revoke_device_key(
self,
session: object,
principal: object,
*,
tenant_id: str,
key_id: str,
expected_epoch: int,
reason: str,
) -> DeviceKeyRef: ...
def list_device_keys(
self,
session: object,
principal: object,
*,
tenant_id: str,
account_id: str,
active_only: bool = True,
) -> tuple[DeviceKeyRef, ...]: ...
def rotate_epoch(
self,
session: object,
principal: object,
*,
request: KeyEpochRotationRequest,
) -> KeyEpochRef: ...
def resolve_epoch(
self,
session: object,
*,
tenant_id: str,
subject_kind: TrustSubjectKind,
subject_id: str,
epoch: int | None = None,
) -> KeyEpochRef | None: ...
def decide_key_access(
self,
session: object,
principal: object,
*,
request: KeyAccessRequest,
) -> KeyAccessDecision: ...
@runtime_checkable
class IdentityTrustAssurance(Protocol):
def verify_assurance(
self,
session: object,
principal: object,
*,
request: AssuranceCheckRequest,
) -> AssuranceDecision: ...
def identity_trust_directory(
registry: object | None,
) -> IdentityTrustDirectory | None:
capability = _capability(registry, CAPABILITY_IDENTITY_TRUST_DIRECTORY)
return capability if isinstance(capability, IdentityTrustDirectory) else None
def identity_trust_assurance(
registry: object | None,
) -> IdentityTrustAssurance | None:
capability = _capability(registry, CAPABILITY_IDENTITY_TRUST_ASSURANCE)
return capability if isinstance(capability, IdentityTrustAssurance) else None
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
def _validate_contract(value: str) -> None:
if value != IDENTITY_TRUST_CONTRACT_VERSION:
raise ValueError("Unsupported identity-trust contract version")
def _require_text(value: str, label: str) -> None:
if not value.strip():
raise ValueError(f"{label.capitalize()} is required")
__all__ = [
"AssuranceCheckRequest",
"AssuranceDecision",
"CAPABILITY_IDENTITY_TRUST_ASSURANCE",
"CAPABILITY_IDENTITY_TRUST_DIRECTORY",
"DeviceKeyRef",
"DeviceKeyRegistration",
"IdentityTrustAssurance",
"IdentityTrustDirectory",
"KeyAccessDecision",
"KeyAccessRequest",
"KeyEpochRef",
"KeyEpochRotationRequest",
"identity_trust_assurance",
"identity_trust_directory",
]
File diff suppressed because it is too large Load Diff
+223
View File
@@ -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",
]
+44 -15
View File
@@ -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",
]
+96
View File
@@ -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()
+2 -2
View File
@@ -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"])
+7 -9
View File
@@ -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"],
)
+36 -6
View File
@@ -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"])
+40
View File
@@ -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
+2
View File
@@ -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",
+5 -1
View File
@@ -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;
+3 -1
View File
@@ -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<string, LucideIcon> = {
landmark: Landmark,
"layout-template": LayoutTemplate,
"list-tree": ListTree,
"list-checks": ListChecks,
mail: Mail,
notifications: Bell,
organizations: Building2,
@@ -84,6 +85,7 @@ const iconByName: Record<string, LucideIcon> = {
"shield-check": ShieldCheck,
templates: LayoutTemplate,
users: Users,
vote: Vote,
waypoints: Waypoints,
workflow: WorkflowIcon
};
+4
View File
@@ -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))
]
},