feat: declare governed external provider state

This commit is contained in:
2026-08-01 17:47:50 +02:00
parent 41ccd4c807
commit 67392f620f
4 changed files with 380 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
# GovOPlaN Addresses Codex Guide
## Scope
This repository owns reusable postal and electronic address records, address books, normalization, and governed address references for consuming modules.
## Documentation Contract
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
- Keep feature content here; `govoplan-docs` projects it without importing Addresses internals.
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
## Boundaries
- Keep identity and organization ownership in their respective modules.
- Expose optional integrations through Core capabilities and typed references.
@@ -27,7 +27,18 @@ from govoplan_core.core.modules import (
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.provider_governance import (
ExternalProviderDeclaration,
ExternalProviderStateProviderRegistration,
ProviderBehaviorDeclaration,
ProviderObjectDeclaration,
declared_module_architecture,
)
from govoplan_core.db.base import Base
from govoplan_addresses.backend.provider_state import (
CARDDAV_PROVIDER_ID,
carddav_provider_states,
)
_addresses_table_retirement_provider = drop_table_retirement_provider(
@@ -152,6 +163,59 @@ def _addresses_router(_context: ModuleContext):
return router
CARDDAV_PROVIDER = ExternalProviderDeclaration(
id=CARDDAV_PROVIDER_ID,
module_id="addresses",
label="CardDAV address-book synchronization",
maturity="synchronize",
operations=("discover", "read", "write", "delete", "synchronize", "preview"),
objects=(
ProviderObjectDeclaration(
object_type="address_book",
field_groups=("identity", "display", "sync_state"),
authority_modes=("external_authoritative", "external_mirror", "governed_sync"),
default_authority_mode="external_mirror",
),
ProviderObjectDeclaration(
object_type="contact",
field_groups=("identity", "name", "postal", "email", "phone", "source_metadata"),
authority_modes=("external_authoritative", "external_mirror", "governed_sync"),
default_authority_mode="governed_sync",
),
),
behavior=ProviderBehaviorDeclaration(
revision_tokens="CardDAV sync tokens, resource hrefs, and ETags are retained.",
concurrency="Conditional writes reject stale ETags and preserve explicit conflicts.",
freshness="Last attempt, last success, source status, and sync token are recorded.",
health="Transport failures, diagnostics, and unresolved conflicts are projected separately.",
max_read_items=5000,
idempotency="Stable source, href, UID, and ETag facts prevent duplicate contact effects.",
retry="Only a new governed sync attempt retries failed transport operations.",
timeout_seconds=30,
conflicts="Local and remote values remain in an explicit conflict record until resolved.",
outcome_unknown="Timed-out writes require a subsequent CardDAV read before correction or retry.",
outcome_unknown_supported=True,
evidence="Sync diagnostics, tombstones, conflicts, source revisions, and contact provenance are retained.",
audit_event_types=(
"addresses.sync.started",
"addresses.sync.finished",
"addresses.sync.conflict_recorded",
),
correction="A resolved conflict or later synchronized revision corrects state without rewriting prior evidence.",
rollback="Remote writes are not assumed to be transactionally reversible.",
compensation="A reconciled update or tombstone can compensate after the remote outcome is known.",
reconciliation="Read by resource href and compare ETag, UID, and local revision before applying changes.",
outage="Existing local contacts remain available with stale or unknown freshness.",
classifications=("personal", "confidential"),
purposes=("address-book synchronization", "governed recipient resolution"),
retention="Address-book and audit retention policies apply independently.",
secret_handling="Only credential references and sanitized authentication metadata are persisted in sync state.",
),
capability_names=(CAPABILITY_ADDRESSES_LOOKUP, CAPABILITY_ADDRESSES_CONTACT_WRITER),
documentation_topic_ids=("addresses.boundary",),
)
manifest = ModuleManifest(
id="addresses",
name="Addresses",
@@ -234,6 +298,33 @@ manifest = ModuleManifest(
order=30,
),
),
external_providers=(CARDDAV_PROVIDER,),
external_provider_state_providers=(
ExternalProviderStateProviderRegistration(
module_id="addresses",
provider_id=CARDDAV_PROVIDER_ID,
provider=carddav_provider_states,
),
),
architecture=declared_module_architecture(
layer="communication_participation",
kind="domain",
maturity="vertical_slice",
documentation_ref="docs/ADDRESS_MODULE_ARCHITECTURE.md",
test_ref="tests/test_addresses_service.py",
known_limits=("External address-book synchronization remains a bounded connector slice rather than a supported provider profile.",),
supported_authority_modes=(
"native_authoritative",
"external_authoritative",
"external_mirror",
"governed_sync",
),
owned_concepts=("contact point", "address book", "contact consent", "recipient source"),
non_owned_concepts=("identity", "organization", "campaign recipient snapshot", "procedure party"),
target_tested_providers=(CARDDAV_PROVIDER_ID,),
security_docs=("docs/ADDRESS_MODULE_ARCHITECTURE.md",),
operations_docs=("README.md",),
),
)
@@ -0,0 +1,164 @@
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_addresses.backend.db.models import (
AddressSyncConflict,
AddressSyncDiagnostic,
AddressSyncSource,
)
from govoplan_core.core.provider_governance import (
ExternalProviderRuntimeState,
ExternalProviderStateContext,
)
CARDDAV_PROVIDER_ID = "addresses.carddav_sync"
_CURRENT_WINDOW = timedelta(hours=24)
def carddav_provider_states(
context: ExternalProviderStateContext,
) -> tuple[ExternalProviderRuntimeState, ...]:
if not isinstance(context.session, Session):
raise RuntimeError("Addresses provider state requires a database session.")
statement = select(AddressSyncSource).where(
AddressSyncSource.connector_type == "carddav"
)
if context.tenant_id is not None:
statement = statement.where(AddressSyncSource.tenant_id == context.tenant_id)
sources = tuple(
context.session.scalars(
statement.order_by(AddressSyncSource.tenant_id, AddressSyncSource.id).limit(
context.max_items + 1
)
)
)
if not sources:
return ()
source_ids = tuple(item.id for item in sources)
conflict_counts = _grouped_counts(
context.session,
AddressSyncConflict.sync_source_id,
AddressSyncConflict.status == "open",
source_ids,
)
error_counts = _grouped_counts(
context.session,
AddressSyncDiagnostic.sync_source_id,
AddressSyncDiagnostic.severity == "error",
source_ids,
)
observed_at = datetime.now(UTC)
return tuple(
_source_state(
source,
observed_at=observed_at,
conflict_count=conflict_counts.get(source.id, 0),
error_count=error_counts.get(source.id, 0),
)
for source in sources
)
def _grouped_counts(
session: Session,
source_column: object,
predicate: object,
source_ids: tuple[str, ...],
) -> dict[str, int]:
counts: dict[str, int] = defaultdict(int)
rows = session.execute(
select(source_column, func.count()).where(
source_column.in_(source_ids), predicate
).group_by(source_column)
)
for source_id, count in rows:
counts[str(source_id)] = int(count)
return counts
def _source_state(
source: AddressSyncSource,
*,
observed_at: datetime,
conflict_count: int,
error_count: int,
) -> ExternalProviderRuntimeState:
active = bool(source.enabled)
status = str(source.status or "idle")
health = (
"inactive"
if not active
else "error"
if status == "failed" or bool(source.last_error)
else "warning"
if status in {"conflict", "running"} or conflict_count or error_count
else "healthy"
if status == "succeeded"
else "unknown"
)
freshness = _freshness(source, observed_at=observed_at)
conflict = "pending" if conflict_count or status == "conflict" else "clear"
recovery = (
"not_applicable"
if not active
else "attention"
if health in {"error", "warning"} or conflict == "pending"
else "ready"
)
return ExternalProviderRuntimeState(
provider_id=CARDDAV_PROVIDER_ID,
binding_ref=f"addresses:sync-source:{source.id}",
authority_mode=(
"external_mirror" if source.read_only else "governed_sync"
),
observed_at=observed_at,
configured=True,
active=active,
health=health,
freshness=freshness,
conflict=conflict,
recovery=recovery,
last_success_at=_aware(source.last_success_at),
detail=(
"CardDAV source is disabled."
if not active
else "CardDAV source requires reconciliation."
if conflict == "pending"
else "CardDAV source health has not been observed yet."
if health == "unknown"
else "CardDAV source state is available."
),
metrics={
"open_conflicts": conflict_count,
"error_diagnostics": error_count,
"read_only": bool(source.read_only),
"status": status,
},
)
def _freshness(source: AddressSyncSource, *, observed_at: datetime) -> str:
if not source.enabled:
return "not_applicable"
last_success = _aware(source.last_success_at)
if last_success is None:
return "unknown"
return "current" if observed_at - last_success <= _CURRENT_WINDOW else "stale"
def _aware(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
__all__ = ["CARDDAV_PROVIDER_ID", "carddav_provider_states"]
+109
View File
@@ -0,0 +1,109 @@
from __future__ import annotations
from datetime import UTC, datetime
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_addresses.backend.db.models import (
AddressBook,
AddressSyncConflict,
AddressSyncDiagnostic,
AddressSyncSource,
)
from govoplan_addresses.backend.manifest import manifest
from govoplan_addresses.backend.provider_state import (
CARDDAV_PROVIDER_ID,
carddav_provider_states,
)
from govoplan_core.core.provider_governance import ExternalProviderStateContext
from govoplan_core.db.base import Base
class AddressesProviderStateTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(
self.engine,
tables=(
AddressBook.__table__,
AddressSyncSource.__table__,
AddressSyncConflict.__table__,
AddressSyncDiagnostic.__table__,
),
)
self.session = sessionmaker(bind=self.engine, expire_on_commit=False)()
book = AddressBook(
id="book-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Remote",
)
self.source = AddressSyncSource(
id="source-1",
tenant_id="tenant-1",
address_book_id=book.id,
connector_type="carddav",
display_name="CardDAV",
external_address_book_ref="https://dav.example.test/addressbook/",
sync_direction="two_way",
read_only=False,
enabled=True,
status="succeeded",
last_success_at=datetime.now(UTC),
)
self.session.add_all((book, self.source))
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_state_is_tenant_bounded_secret_free_and_reports_conflict(self) -> None:
healthy = carddav_provider_states(
ExternalProviderStateContext(session=self.session, tenant_id="tenant-1")
)[0]
self.assertEqual("healthy", healthy.health)
self.assertEqual("current", healthy.freshness)
self.assertEqual("governed_sync", healthy.authority_mode)
self.assertNotIn("dav.example.test", str(healthy.to_dict()))
self.assertEqual(
(),
carddav_provider_states(
ExternalProviderStateContext(
session=self.session,
tenant_id="tenant-2",
)
),
)
self.session.add(
AddressSyncConflict(
tenant_id="tenant-1",
sync_source_id=self.source.id,
address_book_id="book-1",
field_path="email",
status="open",
)
)
self.session.flush()
conflicted = carddav_provider_states(
ExternalProviderStateContext(session=self.session, tenant_id="tenant-1")
)[0]
self.assertEqual("pending", conflicted.conflict)
self.assertEqual("attention", conflicted.recovery)
def test_manifest_registers_carddav_declaration_and_state(self) -> None:
self.assertEqual(CARDDAV_PROVIDER_ID, manifest.external_providers[0].id)
self.assertEqual(
CARDDAV_PROVIDER_ID,
manifest.external_provider_state_providers[0].provider_id,
)
if __name__ == "__main__":
unittest.main()