feat: declare governed external provider state

This commit is contained in:
2026-08-01 17:48:23 +02:00
parent ccdf12162a
commit 66a6df4f05
5 changed files with 761 additions and 0 deletions
+286
View File
@@ -16,6 +16,7 @@ from govoplan_core.core.calendar import (
)
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import (
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
@@ -26,10 +27,225 @@ from govoplan_core.core.modules import (
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.provider_governance import (
ExternalProviderDeclaration,
ExternalProviderStateProviderRegistration,
ModuleArchitectureDeclaration,
ModuleArchitectureDocumentation,
ModuleMaturityEvidence,
ProviderBehaviorDeclaration,
ProviderObjectDeclaration,
)
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
CALDAV_PROVIDER_ID = "calendar.caldav_sync"
ICS_PROVIDER_ID = "calendar.ics_subscription"
GRAPH_PROVIDER_ID = "calendar.microsoft_graph"
EWS_PROVIDER_ID = "calendar.exchange_ews"
CALENDAR_ARCHITECTURE = ModuleArchitectureDeclaration(
layer="communication_participation",
kind="domain",
maturity="vertical_slice",
evidence=(
ModuleMaturityEvidence(
kind="test",
reference="tests/test_caldav.py",
summary="Exercises CalDAV discovery, pull, push, deletion, and reconciliation behavior.",
),
ModuleMaturityEvidence(
kind="test",
reference="tests/test_outbox.py",
summary="Exercises durable calendar effects, retries, terminal states, and cleanup.",
),
ModuleMaturityEvidence(
kind="documentation",
reference="docs/CALENDAR_INTEGRATION_CONCEPT.md",
summary="Documents Calendar ownership and optional integration boundaries.",
),
),
known_limits=(
"Provider target-environment and recovery-drill evidence is not yet packaged as a reference package.",
"Microsoft Graph and EWS adapters do not yet have the same write/reconciliation coverage as CalDAV.",
),
supported_authority_modes=(
"native_authoritative",
"external_authoritative",
"external_mirror",
"governed_sync",
"linked_reference",
),
owned_concepts=(
"calendar collections",
"VEVENT lifecycle",
"recurrence and availability",
"calendar synchronization state",
),
non_owned_concepts=(
"meeting polls",
"mail delivery",
"task lifecycle",
"external identity and credential secrets",
),
target_tested_providers=(CALDAV_PROVIDER_ID,),
documentation=ModuleArchitectureDocumentation(
migration=("src/govoplan_calendar/backend/migrations/versions",),
upgrade=("docs/CALENDAR_INTEGRATION_CONCEPT.md",),
recovery=("docs/CALENDAR_INTEGRATION_CONCEPT.md",),
security=("tests/test_caldav_security.py", "tests/test_http_security.py"),
operations=("docs/CALENDAR_INTEGRATION_CONCEPT.md",),
),
)
def _read_only_calendar_provider(
*,
provider_id: str,
label: str,
source_name: str,
) -> ExternalProviderDeclaration:
return ExternalProviderDeclaration(
id=provider_id,
module_id="calendar",
label=label,
maturity="read",
operations=("read", "preview"),
objects=(
ProviderObjectDeclaration(
object_type="calendar_collection",
field_groups=("identity", "display", "sync_state"),
authority_modes=("external_authoritative", "external_mirror"),
default_authority_mode="external_mirror",
),
ProviderObjectDeclaration(
object_type="calendar_event",
field_groups=(
"identity",
"schedule",
"recurrence",
"participants",
"content",
),
authority_modes=("external_authoritative", "external_mirror"),
default_authority_mode="external_mirror",
),
),
behavior=ProviderBehaviorDeclaration(
revision_tokens=f"{source_name} object identity, remote revision, and content fingerprint are retained.",
concurrency="The source is inbound-only; refresh compares remote identity and revision before replacing the derived projection.",
freshness="Last attempt, last success, and source status are retained.",
health="Authentication, transport, parsing, and source failures are recorded without exposing credentials.",
max_read_items=5000,
evidence=f"Imported events retain the {source_name} source, remote identity, revision, and normalized content fingerprint.",
audit_event_types=("calendar.sync.requested", "calendar.sync.completed"),
correction="A later successful refresh creates the corrected local projection while source authority remains external.",
reconciliation="Re-read the bounded source and compare remote identity, revision, and normalized event content.",
outage="The last local projection remains available with stale or unknown freshness.",
classifications=("internal", "confidential", "restricted"),
purposes=("calendar subscription", "availability", "meeting coordination"),
retention="Calendar projection, sync evidence, and credential retention are independent policies.",
secret_handling="Authentication material remains in Calendar credential records or deployment-owned secret references.",
),
documentation_topic_ids=("calendar.external-sources-and-sync",),
)
CALENDAR_EXTERNAL_PROVIDERS = (
ExternalProviderDeclaration(
id=CALDAV_PROVIDER_ID,
module_id="calendar",
label="CalDAV calendar synchronization",
maturity="synchronize",
operations=(
"discover",
"read",
"write",
"delete",
"synchronize",
"preview",
),
objects=(
ProviderObjectDeclaration(
object_type="calendar_collection",
field_groups=("identity", "display", "sync_state"),
authority_modes=(
"native_authoritative",
"external_authoritative",
"external_mirror",
"governed_sync",
),
default_authority_mode="governed_sync",
),
ProviderObjectDeclaration(
object_type="calendar_event",
field_groups=(
"identity",
"schedule",
"recurrence",
"participants",
"content",
),
authority_modes=(
"native_authoritative",
"external_authoritative",
"external_mirror",
"governed_sync",
),
default_authority_mode="governed_sync",
),
),
behavior=ProviderBehaviorDeclaration(
revision_tokens="CalDAV sync tokens and per-resource ETags are retained.",
concurrency="Conditional requests reject stale ETags; conflicts remain explicit.",
freshness="Last attempt, last success, sync token, and pending outbox work are visible.",
health="Discovery, authentication, transport, collection, and reconciliation failures are recorded.",
max_read_items=5000,
idempotency="Stable VEVENT UID, source id, and durable operation keys suppress duplicate effects.",
retry="Only classified transient failures receive bounded backoff retries.",
timeout_seconds=30,
conflicts="Stale revisions and concurrent remote changes enter conflict/reconciliation state.",
outcome_unknown="A timed-out remote write is outcome-unknown and is not blindly repeated.",
outcome_unknown_supported=True,
evidence="Operation id, request intent, ETag/sync token, response classification, and reconciliation result are retained.",
audit_event_types=(
"calendar.sync.requested",
"calendar.sync.completed",
"calendar.sync.conflict",
"calendar.sync.reconciled",
),
correction="A later conditional update or tombstone corrects external state after reconciliation.",
rollback="Remote effects are not treated as transactionally rollback-safe.",
compensation="A compensating event update/delete may be queued after the remote state is known.",
reconciliation="Read the resource by href/UID, compare ETag and content, then classify applied, retryable, conflict, or manual.",
outage="Local calendars remain usable with stale markers; pending writes stay durable and bounded.",
classifications=("internal", "confidential", "restricted"),
purposes=("calendar collaboration", "availability", "meeting coordination"),
retention="Calendar event, outbox terminal-state, audit, and credential retention are independent policies.",
secret_handling="Authentication material is stored through Calendar credential records or external secret references and is never emitted in provider metadata.",
),
capability_names=(CAPABILITY_CALENDAR_OUTBOX,),
interface_names=("calendar.outbox",),
documentation_topic_ids=("calendar.external-sources-and-sync",),
),
_read_only_calendar_provider(
provider_id=ICS_PROVIDER_ID,
label="ICS and webcal subscription",
source_name="ICS/webcal",
),
_read_only_calendar_provider(
provider_id=GRAPH_PROVIDER_ID,
label="Microsoft Graph calendar projection",
source_name="Microsoft Graph",
),
_read_only_calendar_provider(
provider_id=EWS_PROVIDER_ID,
label="Exchange Web Services calendar projection",
source_name="Exchange Web Services",
),
)
_calendar_table_retirement_provider = drop_table_retirement_provider(
calendar_models.CalendarCollection,
calendar_models.CalendarEvent,
@@ -185,6 +401,30 @@ def _calendar_outbox_provider(context: ModuleContext) -> object:
)
def _caldav_provider_states(context):
from govoplan_calendar.backend.provider_state import caldav_provider_states
return caldav_provider_states(context)
def _ics_provider_states(context):
from govoplan_calendar.backend.provider_state import ics_provider_states
return ics_provider_states(context)
def _graph_provider_states(context):
from govoplan_calendar.backend.provider_state import graph_provider_states
return graph_provider_states(context)
def _ews_provider_states(context):
from govoplan_calendar.backend.provider_state import ews_provider_states
return ews_provider_states(context)
manifest = ModuleManifest(
id="calendar",
name="Calendar",
@@ -200,6 +440,52 @@ manifest = ModuleManifest(
route_factory=_calendar_router,
role_templates=ROLE_TEMPLATES,
tenant_summary_providers=(_tenant_summary,),
architecture=CALENDAR_ARCHITECTURE,
external_providers=CALENDAR_EXTERNAL_PROVIDERS,
external_provider_state_providers=(
ExternalProviderStateProviderRegistration(
module_id="calendar",
provider_id=CALDAV_PROVIDER_ID,
provider=_caldav_provider_states,
),
ExternalProviderStateProviderRegistration(
module_id="calendar",
provider_id=ICS_PROVIDER_ID,
provider=_ics_provider_states,
),
ExternalProviderStateProviderRegistration(
module_id="calendar",
provider_id=GRAPH_PROVIDER_ID,
provider=_graph_provider_states,
),
ExternalProviderStateProviderRegistration(
module_id="calendar",
provider_id=EWS_PROVIDER_ID,
provider=_ews_provider_states,
),
),
documentation=(
DocumentationTopic(
id="calendar.manage-calendars-and-events",
title="Use calendars and events",
summary="Create calendar collections and work with all-day or timed events in continuous, month, week, workweek, and day views.",
body="Calendar remembers the selected view and preferences. Events can be created, edited, moved, resized, repeated, imported, exported, or deleted when the current account has the corresponding permission. All-day events use dates rather than local clock times; timed events retain their timezone-aware start and end values.",
documentation_types=("user",),
audience=("user", "calendar_manager"),
related_modules=("scheduling", "notifications"),
metadata={"kind": "reference"},
),
DocumentationTopic(
id="calendar.external-sources-and-sync",
title="Connect and synchronize external calendars",
summary="Calendar supports local collections, two-way CalDAV, and read-only ICS/webcal, Microsoft Graph, and Exchange Web Services sources.",
body="Each external source keeps its URL, synchronization direction, status, and credential reference with the Calendar collection. Manual or scheduled synchronization records bounded outcomes. CalDAV writes use conditional requests and durable outbox state; conflicts and unknown outcomes require synchronization or explicit reconciliation instead of blind repetition. Removing an external source removes the connection, while deleting a local calendar deletes its owned events after confirmation or transfer.",
documentation_types=("admin", "user"),
audience=("user", "calendar_manager", "operator"),
related_modules=("connectors", "audit", "ops"),
metadata={"kind": "reference"},
),
),
capability_factories={
CAPABILITY_CALENDAR_OUTBOX: _calendar_outbox_provider,
CAPABILITY_CALENDAR_SCHEDULING: _calendar_scheduling_provider,
@@ -0,0 +1,279 @@
from __future__ import annotations
from collections import defaultdict
from datetime import UTC, datetime, timedelta
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from govoplan_calendar.backend.db.models import (
CalendarOutboxOperation,
CalendarSyncSource,
)
from govoplan_core.core.provider_governance import (
ExternalProviderRuntimeState,
ExternalProviderStateContext,
)
CALDAV_PROVIDER_ID = "calendar.caldav_sync"
ICS_PROVIDER_ID = "calendar.ics_subscription"
GRAPH_PROVIDER_ID = "calendar.microsoft_graph"
EWS_PROVIDER_ID = "calendar.exchange_ews"
_HEALTHY_STATUSES = frozenset({"ok", "outbound_ok"})
_ERROR_STATUSES = frozenset({"error", "outbound_error"})
_WARNING_STATUSES = frozenset(
{"outbound_retry", "outbound_cancelled", "outbound_discarded"}
)
_ACTIVE_OUTBOX_STATUSES = frozenset({"pending", "retry", "in_progress"})
def caldav_provider_states(
context: ExternalProviderStateContext,
) -> tuple[ExternalProviderRuntimeState, ...]:
return _provider_states(
context,
provider_id=CALDAV_PROVIDER_ID,
source_kinds=("caldav",),
include_outbox=True,
)
def ics_provider_states(
context: ExternalProviderStateContext,
) -> tuple[ExternalProviderRuntimeState, ...]:
return _provider_states(
context,
provider_id=ICS_PROVIDER_ID,
source_kinds=("ics", "webcal"),
)
def graph_provider_states(
context: ExternalProviderStateContext,
) -> tuple[ExternalProviderRuntimeState, ...]:
return _provider_states(
context,
provider_id=GRAPH_PROVIDER_ID,
source_kinds=("graph",),
)
def ews_provider_states(
context: ExternalProviderStateContext,
) -> tuple[ExternalProviderRuntimeState, ...]:
return _provider_states(
context,
provider_id=EWS_PROVIDER_ID,
source_kinds=("ews",),
)
def _provider_states(
context: ExternalProviderStateContext,
*,
provider_id: str,
source_kinds: tuple[str, ...],
include_outbox: bool = False,
) -> tuple[ExternalProviderRuntimeState, ...]:
if not isinstance(context.session, Session):
raise RuntimeError("Calendar provider state requires a database session.")
statement = select(CalendarSyncSource).where(
CalendarSyncSource.source_kind.in_(source_kinds),
CalendarSyncSource.deleted_at.is_(None),
)
if context.tenant_id is not None:
statement = statement.where(
CalendarSyncSource.tenant_id == context.tenant_id
)
sources = tuple(
context.session.scalars(
statement.order_by(
CalendarSyncSource.tenant_id,
CalendarSyncSource.id,
).limit(context.max_items + 1)
)
)
if not sources:
return ()
counts = (
_outbox_counts(context.session, tuple(item.id for item in sources))
if include_outbox
else {}
)
observed_at = datetime.now(UTC)
return tuple(
_source_state(
source,
provider_id=provider_id,
observed_at=observed_at,
outbox_counts=counts.get(source.id, {}),
)
for source in sources
)
def _outbox_counts(
session: Session,
source_ids: tuple[str, ...],
) -> dict[str, dict[str, int]]:
result: dict[str, dict[str, int]] = defaultdict(dict)
if not source_ids:
return result
rows = session.execute(
select(
CalendarOutboxOperation.source_id,
CalendarOutboxOperation.status,
func.count(CalendarOutboxOperation.id),
)
.where(CalendarOutboxOperation.source_id.in_(source_ids))
.group_by(
CalendarOutboxOperation.source_id,
CalendarOutboxOperation.status,
)
)
for source_id, status, count in rows:
result[str(source_id)][str(status)] = int(count)
return result
def _source_state(
source: CalendarSyncSource,
*,
provider_id: str,
observed_at: datetime,
outbox_counts: dict[str, int],
) -> ExternalProviderRuntimeState:
active = bool(source.sync_enabled)
conflict_count = int(outbox_counts.get("conflict", 0))
dead_count = int(outbox_counts.get("dead", 0))
pending_count = sum(
int(outbox_counts.get(status, 0))
for status in _ACTIVE_OUTBOX_STATUSES
)
health = _health_state(
active=active,
last_status=source.last_status,
conflict_count=conflict_count,
dead_count=dead_count,
pending_count=pending_count,
)
conflict = (
"blocked"
if dead_count
else "pending"
if conflict_count
else "clear"
)
freshness = _freshness_state(source, observed_at=observed_at)
recovery = (
"not_applicable"
if not active
else "attention"
if conflict_count or dead_count or health == "error"
else "ready"
)
return ExternalProviderRuntimeState(
provider_id=provider_id,
binding_ref=f"calendar:sync-source:{source.id}",
authority_mode=(
"governed_sync"
if source.source_kind == "caldav" and source.sync_direction == "two_way"
else "external_mirror"
),
observed_at=observed_at,
configured=True,
active=active,
health=health,
freshness=freshness,
conflict=conflict,
recovery=recovery,
last_success_at=_aware(source.last_synced_at),
detail=_state_detail(
active=active,
health=health,
freshness=freshness,
conflict=conflict,
),
metrics={
"pending_operations": pending_count,
"conflict_operations": conflict_count,
"dead_operations": dead_count,
"sync_interval_seconds": source.sync_interval_seconds,
},
)
def _health_state(
*,
active: bool,
last_status: str | None,
conflict_count: int,
dead_count: int,
pending_count: int,
) -> str:
if not active:
return "inactive"
if dead_count or last_status in _ERROR_STATUSES:
return "error"
if conflict_count or last_status in _WARNING_STATUSES:
return "warning"
if last_status in _HEALTHY_STATUSES:
return "healthy"
if pending_count:
return "warning"
return "unknown"
def _freshness_state(
source: CalendarSyncSource,
*,
observed_at: datetime,
) -> str:
if not source.sync_enabled:
return "not_applicable"
last_synced_at = _aware(source.last_synced_at)
if last_synced_at is None:
return "unknown"
interval = max(int(source.sync_interval_seconds or 0), 60)
grace = timedelta(seconds=max(interval * 2, 1_800))
return "current" if observed_at - last_synced_at <= grace else "stale"
def _state_detail(
*,
active: bool,
health: str,
freshness: str,
conflict: str,
) -> str:
if not active:
return "Synchronization is configured but disabled."
if conflict != "clear":
return "Synchronization has unresolved external outcomes."
if health == "error":
return "The latest synchronization attempt failed."
if freshness == "stale":
return "The last successful synchronization is stale."
if health == "healthy":
return "Synchronization is healthy."
return "Synchronization has not produced a conclusive health observation."
def _aware(value: datetime | None) -> datetime | None:
if value is None:
return None
return value.replace(tzinfo=UTC) if value.tzinfo is None else value
__all__ = [
"CALDAV_PROVIDER_ID",
"EWS_PROVIDER_ID",
"GRAPH_PROVIDER_ID",
"ICS_PROVIDER_ID",
"caldav_provider_states",
"ews_provider_states",
"graph_provider_states",
"ics_provider_states",
]