Add cross-module operational and interaction contracts
This commit is contained in:
@@ -157,7 +157,7 @@ release evidence.
|
||||
| --- | --- | --- |
|
||||
| `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. |
|
||||
| `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. |
|
||||
| `CELERY_QUEUES` | `send_email,append_sent,notifications,calendar,dataflow,workflow,events,default` | Queue list expected by worker/process manager definitions. The `events` queue drains transactional platform events; `dataflow` drains trigger deliveries and schedules; `workflow` reconciles Workflow Engine instances and module standards. |
|
||||
| `CELERY_QUEUES` | `send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default` | Queue list expected by worker/process manager definitions. Keep this aligned with every enabled module task route; Ops reports missing worker queue consumers. |
|
||||
| `PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS` | `8` | Failed durable consumer deliveries are quarantined after this many attempts. |
|
||||
| `PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS` | `90` | Successful event envelopes older than this are removed by the daily retention task. Quarantined evidence is retained. |
|
||||
|
||||
@@ -165,7 +165,7 @@ Worker command:
|
||||
|
||||
```bash
|
||||
python -m celery -A govoplan_core.celery_app:celery worker \
|
||||
--queues send_email,append_sent,notifications,calendar,dataflow,events,default \
|
||||
--queues send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default \
|
||||
--loglevel INFO
|
||||
```
|
||||
|
||||
@@ -343,7 +343,7 @@ the checked in `.env.example`. It runs:
|
||||
- explicit `ENABLED_MODULES`
|
||||
- explicit migrations and `--with-dev-data` bootstrap
|
||||
- API via the module-aware devserver
|
||||
- a Celery worker for `send_email,append_sent,notifications,calendar,default`
|
||||
- a Celery worker for `send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default`
|
||||
- WebUI through the Vite dev server
|
||||
- durable local files under `runtime/production-like/files`
|
||||
|
||||
@@ -462,7 +462,9 @@ Run the rollback drill before relying on installer automation in a new
|
||||
environment:
|
||||
|
||||
```bash
|
||||
/mnt/DATA/git/govoplan/tools/checks/module-installer-rollback-drill.py --format json
|
||||
/mnt/DATA/git/govoplan/tools/checks/module-installer-rollback-drill.py \
|
||||
--format json \
|
||||
--evidence-path runtime/module-installer/restore-drill-evidence.json
|
||||
```
|
||||
|
||||
The drill uses temporary SQLite databases and simulated package commands. It
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# WebUI Theme Contract
|
||||
|
||||
GovOPlaN supports `system`, `light`, and `dark` as persisted user preferences.
|
||||
`system` follows `prefers-color-scheme` live; it is not resolved permanently at
|
||||
save time. Core applies the resolved mode through `data-theme` on the document
|
||||
root and exposes the selected preference through `data-theme-preference`.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Core owns semantic CSS tokens, native `color-scheme`, preference persistence,
|
||||
the Settings selector, and the shared shell.
|
||||
- Modules consume semantic tokens such as `--surface`, `--text`, `--line`, and
|
||||
the status token families. They may define domain aliases whose values resolve
|
||||
to shared tokens.
|
||||
- User preference selects the mode. Tenant and system policy may provide a
|
||||
future default, but must not silently replace an explicit user choice.
|
||||
- Tenant branding is a separate policy surface and must preserve contrast and
|
||||
status semantics in both modes.
|
||||
|
||||
Do not introduce fixed foreground/background colors in a module merely to make
|
||||
one mode look correct. Add or reuse a semantic Core token, then define both
|
||||
light and dark values. Bitmap content and externally authored HTML are exempt,
|
||||
but their surrounding controls must still use the shared tokens.
|
||||
|
||||
`npm run test:theme-contract` verifies the root behavior and representative
|
||||
Campaign, Calendar, Files, and Mail token consumption. The check runs before a
|
||||
production WebUI build.
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.celery_app import celery
|
||||
from govoplan_core.settings import settings
|
||||
|
||||
|
||||
class CeleryQueueContractTests(unittest.TestCase):
|
||||
def test_default_worker_queues_cover_every_routed_task(self) -> None:
|
||||
configured = {
|
||||
value.strip()
|
||||
for value in settings.celery_queues.split(",")
|
||||
if value.strip()
|
||||
}
|
||||
routes = celery.conf.task_routes
|
||||
self.assertIsInstance(routes, dict)
|
||||
routed = {
|
||||
str(route["queue"])
|
||||
for route in routes.values()
|
||||
if isinstance(route, dict) and route.get("queue")
|
||||
}
|
||||
|
||||
self.assertEqual(set(), routed - configured)
|
||||
|
||||
def test_delivery_tasks_keep_worker_loss_protection(self) -> None:
|
||||
self.assertTrue(celery.conf.task_acks_late)
|
||||
self.assertTrue(celery.conf.task_reject_on_worker_lost)
|
||||
self.assertEqual(1, celery.conf.worker_prefetch_multiplier)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -32,6 +32,18 @@ def workflow_topic(
|
||||
|
||||
|
||||
class DocumentationTopicContractTests(unittest.TestCase):
|
||||
def test_registry_rejects_empty_documentation_version_range(self) -> None:
|
||||
topic = DocumentationTopic(
|
||||
id="example.versioned",
|
||||
title="Versioned",
|
||||
summary="Invalid range",
|
||||
version_min="2.0.0",
|
||||
version_max_exclusive="2.0.0",
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "empty version range"):
|
||||
registry_for(topic).validate()
|
||||
|
||||
def test_user_workflow_requires_scope_conditions(self) -> None:
|
||||
topic = workflow_topic(conditions=())
|
||||
|
||||
|
||||
+2
-1
@@ -26,12 +26,13 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 5173",
|
||||
"prebuild": "npm run audit:i18n-structural",
|
||||
"prebuild": "npm run audit:i18n-structural && npm run test:theme-contract",
|
||||
"build": "tsc && vite build && node scripts/check-bundle-budget.mjs",
|
||||
"check:bundle-budget": "node scripts/check-bundle-budget.mjs",
|
||||
"preview": "vite preview --host 127.0.0.1 --port 4173",
|
||||
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
|
||||
"test:i18n-catalog": "node --test tests/i18n-catalog-validation.test.mjs",
|
||||
"test:theme-contract": "node scripts/test-theme-contract.mjs",
|
||||
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
|
||||
"test:data-grid-actions": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/data-grid-actions.test.js && node .component-test-build/tests/data-grid-sizing.test.js",
|
||||
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const webuiRoot = resolve(import.meta.dirname, "..");
|
||||
const repositoryRoot = resolve(webuiRoot, "..", "..");
|
||||
const tokens = readFileSync(resolve(webuiRoot, "src/styles/tokens.css"), "utf8");
|
||||
const app = readFileSync(resolve(webuiRoot, "src/App.tsx"), "utf8");
|
||||
const settings = readFileSync(resolve(webuiRoot, "src/features/settings/SettingsPage.tsx"), "utf8");
|
||||
|
||||
assert.match(tokens, /:root\[data-theme="dark"\]/, "dark token overrides are required");
|
||||
assert.match(tokens, /color-scheme:\s*dark/, "native controls must receive the dark color scheme");
|
||||
assert.match(app, /matchMedia\?\.\("\(prefers-color-scheme: dark\)"\)/, "system theme must follow OS changes");
|
||||
assert.match(app, /dataset\.themePreference/, "the selected preference must remain inspectable");
|
||||
for (const theme of ["system", "light", "dark"]) {
|
||||
assert.match(settings, new RegExp(`value:\\s*"${theme}"`), `Settings must expose ${theme}`);
|
||||
}
|
||||
|
||||
const representativeModules = [
|
||||
"govoplan-campaign/webui/src/styles/campaign-workspace.css",
|
||||
"govoplan-calendar/webui/src/styles/calendar.css",
|
||||
"govoplan-files/webui/src/styles/file-manager.css",
|
||||
"govoplan-mail/webui/src/styles/mail-profiles.css",
|
||||
];
|
||||
for (const relativePath of representativeModules) {
|
||||
const css = readFileSync(resolve(repositoryRoot, relativePath), "utf8");
|
||||
assert.match(css, /var\(--/, `${relativePath} must consume shared theme tokens`);
|
||||
}
|
||||
|
||||
console.log("Theme contract passed for core and representative module surfaces.");
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { Bell, Check, LogOut, Settings, UserCircle } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse, SearchRuntimeUiCapability, ViewsRuntimeUiCapability } from "../types";
|
||||
import type { ActingContextRuntimeUiCapability, ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse, SearchRuntimeUiCapability, ViewsRuntimeUiCapability } from "../types";
|
||||
import HelpMenu from "./HelpMenu";
|
||||
import LanguageMenu from "./LanguageMenu";
|
||||
import LoginModal from "../features/auth/LoginModal";
|
||||
@@ -37,6 +37,8 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
const projection = useEffectiveView();
|
||||
const viewsRuntime = usePlatformUiCapability<ViewsRuntimeUiCapability>("views.runtime");
|
||||
const ViewSelector = viewsRuntime?.Selector ?? null;
|
||||
const actingRuntime = usePlatformUiCapability<ActingContextRuntimeUiCapability>("access.actingContext");
|
||||
const ActingContextSelector = actingRuntime?.Selector ?? null;
|
||||
const searchRuntime = usePlatformUiCapability<SearchRuntimeUiCapability>("search.runtime");
|
||||
const GlobalSearch = searchRuntime?.GlobalSearch ?? null;
|
||||
const canUseGlobalSearch = Boolean(
|
||||
@@ -60,7 +62,7 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
);
|
||||
const showTenantControl = Boolean(activeTenant && (canSwitchTenant || canAdministerTenants));
|
||||
const showContextSelectors = Boolean(
|
||||
auth && ((activeTenant && showTenantControl) || ViewSelector)
|
||||
auth && ((activeTenant && showTenantControl) || ViewSelector || ActingContextSelector)
|
||||
);
|
||||
const notificationsAvailable = modules.some((module) => module.id === "notifications" && module.routes?.some((route) => route.path === "/notifications"));
|
||||
const notificationSummary = useSharedNotificationSummary(settings, {
|
||||
@@ -206,6 +208,9 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
{ViewSelector &&
|
||||
<ViewSelector settings={settings} auth={auth} projection={projection} />
|
||||
}
|
||||
{ActingContextSelector &&
|
||||
<ActingContextSelector settings={settings} auth={auth} onAuthChange={onAuthChange} />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
.titlebar-actions { grid-column: 2; display: flex; align-items: center; justify-self: end; min-width: 0; gap: 10px; }
|
||||
.titlebar.has-global-search .titlebar-actions { grid-column: 3; }
|
||||
.titlebar-context-selectors { display: flex; align-items: center; min-width: 0; gap: 12px; }
|
||||
.acting-context-selector { display: inline-flex; align-items: center; min-width: 0; gap: 7px; color: var(--muted); }
|
||||
.acting-context-selector select { width: auto; max-width: 260px; min-height: 32px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-strong); font: inherit; font-weight: 700; padding: 5px 24px 5px 8px; }
|
||||
.acting-context-selector select:hover, .acting-context-selector select:focus-visible { background: var(--titlebar-hover-bg); }
|
||||
.tenant-selector { height: 40px; min-width: 210px; border: var(--border-line); border-radius: var(--radius-sm); background: var(--surface); display: flex; align-items: center; padding: 0 12px; gap: 5px; box-shadow: var(--shadow-xs); }
|
||||
.tenant-label, .tenant-caret, .muted { color: var(--muted); }
|
||||
.block { display: block; }
|
||||
@@ -265,6 +268,8 @@
|
||||
.docs-outline { padding-top: 22px; }
|
||||
.docs-sidebar-header { padding: 0 16px 16px; }
|
||||
.docs-sidebar-header .section-title { padding: 0 6px 12px; }
|
||||
.docs-version-selector { display: grid; gap: 5px; margin-top: 12px; color: var(--muted); font-size: 12px; font-weight: 700; }
|
||||
.docs-version-selector select { width: 100%; min-height: 34px; border: var(--border-line); border-radius: var(--radius-sm); background: var(--input-bg); color: var(--text); font: inherit; padding: 5px 8px; }
|
||||
.docs-audience-toggle { width: 100%; }
|
||||
.docs-tree { display: grid; gap: 2px; padding: 0 10px 24px; }
|
||||
.docs-tree-node { min-width: 0; }
|
||||
|
||||
@@ -92,11 +92,22 @@ export type PrincipalContext = {
|
||||
api_key_id?: string | null;
|
||||
session_id?: string | null;
|
||||
service_account_id?: string | null;
|
||||
acting_assignment_id?: string | null;
|
||||
acting_for_account_id?: string | null;
|
||||
email?: string | null;
|
||||
display_name?: string | null;
|
||||
};
|
||||
|
||||
export type ActingContextSelectorProps = {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||
};
|
||||
|
||||
export type ActingContextRuntimeUiCapability = {
|
||||
Selector: ComponentType<ActingContextSelectorProps>;
|
||||
};
|
||||
|
||||
export type AuthInfo = {
|
||||
user: AuthUser;
|
||||
// Backwards-compatible active tenant alias returned by older/newer APIs.
|
||||
|
||||
Reference in New Issue
Block a user