feat: declare governed external provider state
This commit is contained in:
@@ -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,
|
PermissionDefinition,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.provider_governance import (
|
||||||
|
ExternalProviderDeclaration,
|
||||||
|
ExternalProviderStateProviderRegistration,
|
||||||
|
ProviderBehaviorDeclaration,
|
||||||
|
ProviderObjectDeclaration,
|
||||||
|
declared_module_architecture,
|
||||||
|
)
|
||||||
from govoplan_core.db.base import Base
|
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(
|
_addresses_table_retirement_provider = drop_table_retirement_provider(
|
||||||
@@ -152,6 +163,59 @@ def _addresses_router(_context: ModuleContext):
|
|||||||
return router
|
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(
|
manifest = ModuleManifest(
|
||||||
id="addresses",
|
id="addresses",
|
||||||
name="Addresses",
|
name="Addresses",
|
||||||
@@ -234,6 +298,33 @@ manifest = ModuleManifest(
|
|||||||
order=30,
|
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"]
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user