Add cross-module operational and interaction contracts

This commit is contained in:
2026-07-31 22:48:07 +02:00
parent 4cb334c912
commit b65b48832b
23 changed files with 593 additions and 13 deletions
+7
View File
@@ -73,6 +73,12 @@ class SwitchTenantRequest(BaseModel):
tenant_id: str
class SwitchActingContextRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
assignment_id: str | None = Field(default=None, max_length=36)
class TenantInfo(BaseModel):
id: str
slug: str
@@ -178,6 +184,7 @@ class PrincipalContextInfo(BaseModel):
api_key_id: str | None = None
session_id: str | None = None
service_account_id: str | None = None
acting_assignment_id: str | None = None
acting_for_account_id: str | None = None
email: str | None = None
display_name: str | None = None
+4
View File
@@ -81,6 +81,10 @@ class ApiPrincipal:
def acting_for_account_id(self) -> str | None:
return self.principal.acting_for_account_id
@property
def acting_assignment_id(self) -> str | None:
return self.principal.acting_assignment_id
@property
def auth_method(self) -> str:
return self.principal.auth_method
+46 -1
View File
@@ -24,7 +24,12 @@ from govoplan_core.core.idm import (
IdmAssignmentLifecycle,
)
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.mail import CAPABILITY_MAIL_DELIVERY_OUTBOX, MailDeliveryOutboxProvider
from govoplan_core.core.mail import (
CAPABILITY_MAIL_BOUNCE_PROCESSING,
CAPABILITY_MAIL_DELIVERY_OUTBOX,
MailBounceProcessingProvider,
MailDeliveryOutboxProvider,
)
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
from govoplan_core.core.postbox import (
@@ -58,6 +63,7 @@ celery.conf.update(
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
"govoplan.mail.dispatch_outbox": {"queue": "mail"},
"govoplan.mail.purge_outbox": {"queue": "mail"},
"govoplan.mail.scan_bounces": {"queue": "mail"},
"govoplan.calendar.dispatch_outbox": {"queue": "calendar"},
"govoplan.dataflow.dispatch_runs": {"queue": "dataflow"},
"govoplan.dataflow.purge_runs": {"queue": "dataflow"},
@@ -87,6 +93,11 @@ celery.conf.update(
"schedule": 24 * 60 * 60.0,
"args": (250,),
},
"mail-bounces-every-five-minutes": {
"task": "govoplan.mail.scan_bounces",
"schedule": 5 * 60.0,
"args": (None, 250),
},
"dataflow-triggers-every-minute": {
"task": "govoplan.dataflow.dispatch_triggers",
"schedule": 60.0,
@@ -184,6 +195,16 @@ def _mail_delivery_outbox() -> MailDeliveryOutboxProvider | None:
return capability
def _mail_bounce_processing() -> MailBounceProcessingProvider | None:
registry = _platform_registry()
if not registry.has_capability(CAPABILITY_MAIL_BOUNCE_PROCESSING):
return None
capability = registry.require_capability(CAPABILITY_MAIL_BOUNCE_PROCESSING)
if not isinstance(capability, MailBounceProcessingProvider):
raise RuntimeError("Mail bounce-processing capability is invalid")
return capability
def _dataflow_trigger_dispatcher(
registry: PlatformRegistry | None = None,
) -> DataflowTriggerDispatcher | None:
@@ -357,6 +378,30 @@ def purge_mail_outbox(self, limit: int = 250):
return dict(provider.purge_expired(session, limit=limit))
@celery.task(name="govoplan.mail.scan_bounces", bind=True, max_retries=0)
def scan_mail_bounces(
self,
tenant_id: str | None = None,
limit: int = 250,
):
"""Read configured DSN folders without mutating provider mailbox flags."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
provider = _mail_bounce_processing()
if provider is None:
return {
"sources": 0,
"processed_messages": 0,
"observations": 0,
"failures": [],
}
result = dict(provider.scan_due(session, tenant_id=tenant_id, limit=limit))
session.commit()
return result
@celery.task(name="govoplan.calendar.dispatch_outbox", bind=True, max_retries=0)
def dispatch_calendar_outbox(self, tenant_id: str | None = None, limit: int = 50):
"""Drain durable Calendar operations; retry timing lives in the database."""
+3
View File
@@ -125,6 +125,7 @@ class PrincipalRef:
api_key_id: str | None = None
session_id: str | None = None
service_account_id: str | None = None
acting_assignment_id: str | None = None
acting_for_account_id: str | None = None
email: str | None = None
display_name: str | None = None
@@ -144,6 +145,7 @@ class PrincipalRef:
"api_key_id": self.api_key_id,
"session_id": self.session_id,
"service_account_id": self.service_account_id,
"acting_assignment_id": self.acting_assignment_id,
"acting_for_account_id": self.acting_for_account_id,
"email": self.email,
"display_name": self.display_name,
@@ -165,6 +167,7 @@ class PrincipalRef:
api_key_id=_optional_str(value.get("api_key_id")),
session_id=_optional_str(value.get("session_id")),
service_account_id=_optional_str(value.get("service_account_id")),
acting_assignment_id=_optional_str(value.get("acting_assignment_id")),
acting_for_account_id=_optional_str(value.get("acting_for_account_id")),
email=_optional_str(value.get("email")),
display_name=_optional_str(value.get("display_name")),
+96
View File
@@ -8,6 +8,7 @@ from typing import Protocol, runtime_checkable
CAPABILITY_CALENDAR_SCHEDULING = "calendar.scheduling"
CAPABILITY_CALENDAR_OUTBOX = "calendar.outbox"
CAPABILITY_CALENDAR_INVITATIONS = "calendar.invitations"
CALENDAR_AVAILABILITY_READ_SCOPE = "calendar:availability:read"
CALENDAR_EVENT_WRITE_SCOPE = "calendar:event:write"
@@ -43,6 +44,52 @@ class CalendarEventRef:
outbox_operation_id: str | None = None
@dataclass(frozen=True, slots=True)
class CalendarInvitationAttendeeRequest:
address: str
name: str | None = None
role: str = "REQ-PARTICIPANT"
participation_status: str = "NEEDS-ACTION"
rsvp: bool = True
@dataclass(frozen=True, slots=True)
class CalendarInvitationRequest:
correlation_id: str
source_module: str
source_resource_type: str
source_resource_id: str | None
summary: str
start_at: datetime
attendees: tuple[CalendarInvitationAttendeeRequest, ...]
calendar_id: str | None = None
description: str | None = None
location: str | None = None
end_at: datetime | None = None
timezone: str | None = None
organizer: Mapping[str, object] | None = None
classification: str = "PUBLIC"
categories: tuple[str, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class CalendarInvitationRef:
event_id: str
calendar_id: str
uid: str
correlation_id: str
source_module: str
source_resource_type: str
source_resource_id: str | None
attendees: tuple[Mapping[str, object], ...] = ()
external_state: str = "local"
outbox_operation_id: str | None = None
reply_ingress: str = "capability"
recurrence_supported: bool = False
degraded_reasons: tuple[str, ...] = ()
@runtime_checkable
class CalendarSchedulingProvider(Protocol):
def list_freebusy(
@@ -80,6 +127,44 @@ class CalendarOutboxProvider(Protocol):
) -> Mapping[str, object]:
...
@runtime_checkable
class CalendarInvitationProvider(Protocol):
"""Correlation-aware invitation boundary for Campaign and Mail adapters."""
def upsert_invitation(
self,
session: object,
*,
tenant_id: str,
user_id: str | None,
request: CalendarInvitationRequest,
) -> CalendarInvitationRef:
...
def get_invitation(
self,
session: object,
*,
tenant_id: str,
correlation_id: str,
) -> CalendarInvitationRef | None:
...
def record_response(
self,
session: object,
*,
tenant_id: str,
attendee_address: str,
participation_status: str,
correlation_id: str | None = None,
uid: str | None = None,
responded_at: datetime | None = None,
evidence: Mapping[str, object] | None = None,
) -> CalendarInvitationRef:
...
def calendar_scheduling_provider(registry: object | None) -> CalendarSchedulingProvider | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
@@ -96,3 +181,14 @@ def calendar_outbox_provider(registry: object | None) -> CalendarOutboxProvider
return None
capability = registry.capability(CAPABILITY_CALENDAR_OUTBOX)
return capability if isinstance(capability, CalendarOutboxProvider) else None
def calendar_invitation_provider(
registry: object | None,
) -> CalendarInvitationProvider | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_CALENDAR_INVITATIONS):
return None
capability = registry.capability(CAPABILITY_CALENDAR_INVITATIONS)
return capability if isinstance(capability, CalendarInvitationProvider) else None
+122
View File
@@ -0,0 +1,122 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
CAPABILITY_CONNECTORS_FEEDS = "connectors.feeds"
FeedFormat = Literal["rss", "atom"]
FeedVisibility = Literal["public", "tenant", "private"]
class FeedCapabilityError(ValueError):
"""Stable error raised by feed transport implementations."""
@dataclass(frozen=True, slots=True)
class FeedEntry:
id: str
title: str
url: str | None = None
summary: str | None = None
content: str | None = None
author: str | None = None
published_at: datetime | None = None
updated_at: datetime | None = None
categories: tuple[str, ...] = ()
enclosures: tuple[Mapping[str, object], ...] = ()
visibility: FeedVisibility = "public"
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class FeedDocument:
format: FeedFormat
title: str
source_url: str
entries: tuple[FeedEntry, ...]
description: str | None = None
home_url: str | None = None
language: str | None = None
updated_at: datetime | None = None
acquired_at: datetime | None = None
fresh_until: datetime | None = None
etag: str | None = None
last_modified: str | None = None
content_type: str | None = None
sha256: str = ""
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class FeedRenderRequest:
format: FeedFormat
title: str
feed_url: str
home_url: str
entries: tuple[FeedEntry, ...]
description: str | None = None
language: str | None = None
allowed_visibilities: frozenset[FeedVisibility] = frozenset({"public"})
@dataclass(frozen=True, slots=True)
class FeedRenderResult:
format: FeedFormat
content_type: str
body: bytes
included_entries: int
excluded_entries: int
@runtime_checkable
class FeedProvider(Protocol):
def fetch(
self,
url: str,
*,
timeout: float = 15,
max_entries: int = 2_000,
) -> FeedDocument:
...
def parse(
self,
content: bytes,
*,
source_url: str,
content_type: str | None = None,
max_entries: int = 2_000,
) -> FeedDocument:
...
def render(self, request: FeedRenderRequest) -> FeedRenderResult:
...
def feed_provider(registry: object | None) -> FeedProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(CAPABILITY_CONNECTORS_FEEDS)
):
return None
provider = registry.capability(CAPABILITY_CONNECTORS_FEEDS)
return provider if isinstance(provider, FeedProvider) else None
__all__ = [
"CAPABILITY_CONNECTORS_FEEDS",
"FeedCapabilityError",
"FeedDocument",
"FeedEntry",
"FeedFormat",
"FeedProvider",
"FeedRenderRequest",
"FeedRenderResult",
"FeedVisibility",
"feed_provider",
]
+2 -2
View File
@@ -373,7 +373,7 @@ ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,aud
CELERY_ENABLED=true
REDIS_URL=redis://127.0.0.1:6379/0
CELERY_QUEUES=send_email,append_sent,notifications,calendar,dataflow,workflow,events,default
CELERY_QUEUES=send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
@@ -446,7 +446,7 @@ DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
REDIS_URL=redis://127.0.0.1:56379/0
CELERY_ENABLED=true
CELERY_QUEUES=send_email,append_sent,notifications,calendar,dataflow,workflow,events,default
CELERY_QUEUES=send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
+74 -1
View File
@@ -2,11 +2,13 @@ from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
CAPABILITY_MAIL_DELIVERY_OUTBOX = "mail.delivery_outbox"
CAPABILITY_MAIL_NOTIFICATION_DELIVERY = "mail.notificationDelivery"
CAPABILITY_MAIL_BOUNCE_PROCESSING = "mail.bounce_processing"
@dataclass(frozen=True, slots=True)
@@ -36,7 +38,6 @@ class NotificationMailDeliveryProvider(Protocol):
) -> Mapping[str, object]:
...
@runtime_checkable
class MailDeliveryOutboxProvider(Protocol):
"""Stable worker boundary for Mail-owned external delivery effects."""
@@ -60,6 +61,60 @@ class MailDeliveryOutboxProvider(Protocol):
...
@dataclass(frozen=True, slots=True)
class MailBounceObservationRef:
id: str
tenant_id: str
profile_id: str
folder: str
uid: str
original_message_id: str | None
command_id: str | None
recipient: str | None
action: str
status_code: str | None
diagnostic: str | None
permanent: bool
observed_at: datetime
matched: bool
evidence: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class MailBounceProcessingProvider(Protocol):
"""Mail-owned DSN ingestion and durable correlation boundary."""
def process_raw_message(
self,
session: object,
*,
tenant_id: str,
profile_id: str,
folder: str,
uid: str,
raw_message: bytes,
) -> tuple[MailBounceObservationRef, ...]:
...
def scan_due(
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 100,
) -> Mapping[str, object]:
...
def observations_for_commands(
self,
session: object,
*,
tenant_id: str,
command_ids: tuple[str, ...],
) -> Mapping[str, tuple[MailBounceObservationRef, ...]]:
...
def notification_mail_delivery_provider(
registry: object | None,
) -> NotificationMailDeliveryProvider | None:
@@ -76,3 +131,21 @@ def notification_mail_delivery_provider(
"NotificationMailDeliveryProvider"
)
return provider
def mail_bounce_processing_provider(
registry: object | None,
) -> MailBounceProcessingProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_MAIL_BOUNCE_PROCESSING)
):
return None
provider = registry.require_capability(CAPABILITY_MAIL_BOUNCE_PROCESSING)
if not isinstance(provider, MailBounceProcessingProvider):
raise TypeError(
"mail.bounce_processing provider does not implement "
"MailBounceProcessingProvider"
)
return provider
@@ -3679,10 +3679,21 @@ def _snapshot_sqlite_database(run_dir: Path, database_url: str | None) -> dict[s
source.backup(target)
else:
backup_path.touch()
with closing(sqlite3.connect(str(backup_path))) as candidate:
row = candidate.execute("PRAGMA integrity_check").fetchone()
integrity = str(row[0] if row else "missing result")
if integrity.lower() != "ok":
raise ModuleInstallerError(
f"SQLite backup failed its restore-readiness integrity check: {integrity}"
)
return {
"type": "sqlite",
"source": str(db_path),
"path": backup_path.name,
"restore_check": {
"type": "sqlite_integrity_check",
"result": integrity,
},
}
+7
View File
@@ -9,6 +9,7 @@ from govoplan_core.core.views import ViewSurface
if TYPE_CHECKING:
from fastapi import APIRouter
from govoplan_core.core.operations import OperationalCheckProviderRegistration
from govoplan_core.core.search import (
SearchProviderRegistration,
SearchSourceProviderRegistration,
@@ -268,6 +269,8 @@ class DocumentationTopic:
i18n_key: str | None = None
translations: Mapping[str, Mapping[str, str]] = field(default_factory=dict)
source_module_id: str | None = None
version_min: str | None = None
version_max_exclusive: str | None = None
metadata: Mapping[str, Any] = field(default_factory=dict)
@@ -431,6 +434,10 @@ class ModuleManifest:
capability_documentation: Mapping[str, CapabilityDocumentation] = field(default_factory=dict)
search_providers: tuple["SearchProviderRegistration", ...] = ()
search_sources: tuple["SearchSourceProviderRegistration", ...] = ()
operational_check_providers: tuple[
"OperationalCheckProviderRegistration",
...,
] = ()
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
on_activate: LifecycleHook | None = None
on_deactivate: LifecycleHook | None = None
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from typing import Literal
OperationalCheckState = Literal["ok", "warning", "error", "inactive"]
@dataclass(frozen=True, slots=True)
class OperationalCheck:
"""A bounded module-owned runtime check exposed through the Ops module."""
id: str
label: str
state: OperationalCheckState
detail: str
readiness_critical: bool = False
metrics: Mapping[str, object] = field(default_factory=dict)
def as_dict(self) -> dict[str, object]:
return {
"id": self.id,
"label": self.label,
"state": self.state,
"detail": self.detail,
"readiness_critical": self.readiness_critical,
"metrics": dict(self.metrics),
}
OperationalCheckProvider = Callable[[], OperationalCheck]
@dataclass(frozen=True, slots=True)
class OperationalCheckProviderRegistration:
"""Register one independently executable operational check."""
module_id: str
check_id: str
provider: OperationalCheckProvider
cache_seconds: int = 60
+32
View File
@@ -196,6 +196,32 @@ class PostboxAttachmentRef:
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxWrappedKeyRef:
"""Opaque envelope-key record; key material remains crypto-provider owned."""
recipient_type: str
recipient_id: str
key_epoch: int
wrapped_key_ref: str
algorithm: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxExternalRecipientTokenRef:
"""External grant state without the bearer secret itself."""
token_id: str
state: str
expires_at: datetime | None = None
one_time: bool = False
key_fetched_at: datetime | None = None
revoked_at: datetime | None = None
assurance_profile: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxMessageRef:
id: str
@@ -221,6 +247,8 @@ class PostboxMessageRef:
key_epoch: int = 1
ciphertext_ref: str | None = None
signed_manifest_ref: str | None = None
wrapped_keys: tuple[PostboxWrappedKeyRef, ...] = ()
external_recipient_tokens: tuple[PostboxExternalRecipientTokenRef, ...] = ()
participants: tuple[PostboxParticipantRef, ...] = ()
attachments: tuple[PostboxAttachmentRef, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
@@ -275,6 +303,10 @@ class PostboxDeliveryRequest:
participants: tuple[PostboxParticipantRef, ...] = ()
attachments: tuple[PostboxAttachmentRef, ...] = ()
expires_at: datetime | None = None
ciphertext_ref: str | None = None
signed_manifest_ref: str | None = None
wrapped_keys: tuple[PostboxWrappedKeyRef, ...] = ()
external_recipient_tokens: tuple[PostboxExternalRecipientTokenRef, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
+8
View File
@@ -616,6 +616,14 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
for item in manifest.nav_items:
_validate_nav_item(manifest.id, item)
for topic in manifest.documentation:
if not version_range_is_valid(
version_min=topic.version_min,
version_max_exclusive=topic.version_max_exclusive,
):
raise RegistryError(
f"Module {manifest.id!r} documentation topic {topic.id!r} "
"declares an empty version range"
)
for issue in user_workflow_scope_condition_issues(topic):
raise RegistryError(
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
+2 -2
View File
@@ -155,8 +155,8 @@ class Settings(BaseSettings):
master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64")
celery_queues: str = Field(
default=(
"send_email,append_sent,notifications,calendar,"
"dataflow,workflow,events,default"
"send_email,append_sent,notifications,mail,calendar,"
"dataflow,workflow,postbox,events,idm,default"
),
alias="CELERY_QUEUES",
)