Add cross-module operational and interaction contracts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user