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
+6 -4
View File
@@ -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
+28
View File
@@ -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.
+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",
)
+34
View File
@@ -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
View File
@@ -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",
+30
View File
@@ -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.");
+7 -2
View File
@@ -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>
+5
View File
@@ -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; }
+11
View File
@@ -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.