Define connector runtime preview contract

This commit is contained in:
2026-08-02 14:54:44 +02:00
parent c6ef644842
commit af5c6af0e7
3 changed files with 312 additions and 0 deletions
+16
View File
@@ -1491,6 +1491,22 @@ The first implementation is a platform access gate. It does not replace
database backups, process supervision, migration checks, or external load
balancer maintenance pages.
## Connector Runtime Contract
Core defines provider-neutral connector preview and diagnostic primitives in
`govoplan_core.core.connector_runtime`. The contract keeps optional modules
decoupled: Connectors owns transport, endpoint discovery, retries, and protocol
health; the consuming domain module owns mappings, validation, reconciliation,
and mutations of its records.
Every dry run is bounded and identifies the source revision, source fingerprint,
immutable input hash, effects, and redacted diagnostics. Its summary must match
the returned effect list exactly. An apply token is usable only when the preview
is complete, current, conflict-free, and contains no error diagnostic. Endpoint
URLs never contain credentials; only credential-envelope references cross the
contract. Provider-specific details belong in sanitized provenance rather than
in a shared domain schema.
## Build And Verification
Backend verification from core:
+226
View File
@@ -0,0 +1,226 @@
from __future__ import annotations
from collections import Counter
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
from urllib.parse import urlsplit
CONNECTOR_RUNTIME_CONTRACT_VERSION = "1.0"
ConnectorDiagnosticSeverity = Literal["info", "warning", "error"]
ConnectorDiagnosticStage = Literal[
"configuration",
"authentication",
"discovery",
"read",
"mapping",
"planning",
"apply",
"reconciliation",
]
ConnectorEffectKind = Literal[
"create",
"update",
"delete",
"conflict",
"unchanged",
"ignored",
]
ConnectorOutcomeState = Literal[
"preview",
"accepted",
"rejected",
"outcome_unknown",
]
class ConnectorContractError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class ConnectorEndpoint:
"""Sanitized endpoint identity. Credentials never belong in this value."""
url: str
credential_ref: str | None = None
tls_mode: Literal["required", "start_tls", "system", "disabled"] = "required"
options: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
normalized = self.url.strip()
parsed = urlsplit(normalized)
if not parsed.scheme or not parsed.hostname:
raise ConnectorContractError("Connector endpoints require an absolute URL.")
if parsed.username is not None or parsed.password is not None:
raise ConnectorContractError(
"Connector endpoint URLs must not contain credentials."
)
object.__setattr__(self, "url", normalized)
if self.credential_ref is not None:
credential_ref = self.credential_ref.strip()
if not credential_ref:
raise ConnectorContractError("Credential references cannot be blank.")
object.__setattr__(self, "credential_ref", credential_ref)
@dataclass(frozen=True, slots=True)
class ConnectorDiagnostic:
severity: ConnectorDiagnosticSeverity
code: str
message: str
stage: ConnectorDiagnosticStage
retryable: bool = False
source_ref: str | None = None
object_ref: str | None = None
details: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
if not self.code.strip() or len(self.code) > 120:
raise ConnectorContractError(
"Connector diagnostic codes must contain 1 to 120 characters."
)
if not self.message.strip():
raise ConnectorContractError("Connector diagnostic messages cannot be blank.")
@dataclass(frozen=True, slots=True)
class ConnectorEffectPreview:
effect: ConnectorEffectKind
source_object_ref: str
target_object_ref: str | None = None
changed_fields: tuple[str, ...] = ()
sample: Mapping[str, object] = field(default_factory=dict)
reason_code: str | None = None
outcome: ConnectorOutcomeState = "preview"
revision: str | None = None
def __post_init__(self) -> None:
if not self.source_object_ref.strip():
raise ConnectorContractError("Preview effects require a source object reference.")
if self.outcome != "preview":
raise ConnectorContractError("Dry-run effects must retain the preview outcome.")
@dataclass(frozen=True, slots=True)
class ConnectorEffectSummary:
creates: int = 0
updates: int = 0
deletes: int = 0
conflicts: int = 0
unchanged: int = 0
ignored: int = 0
@property
def total(self) -> int:
return (
self.creates
+ self.updates
+ self.deletes
+ self.conflicts
+ self.unchanged
+ self.ignored
)
@dataclass(frozen=True, slots=True)
class ConnectorDryRunRequest:
tenant_id: str
source_ref: str
force_full: bool = False
max_items: int = 1_000
expected_source_revision: str | None = None
context: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
if not self.tenant_id.strip() or not self.source_ref.strip():
raise ConnectorContractError("Dry runs require tenant and source references.")
if not 1 <= self.max_items <= 10_000:
raise ConnectorContractError("Dry-run max_items must be between 1 and 10000.")
@dataclass(frozen=True, slots=True)
class ConnectorDryRunResult:
contract_version: str
source_ref: str
source_revision: str
source_fingerprint: str
input_hash: str
generated_at: datetime
summary: ConnectorEffectSummary
effects: tuple[ConnectorEffectPreview, ...] = ()
diagnostics: tuple[ConnectorDiagnostic, ...] = ()
truncated: bool = False
stale: bool = False
apply_token: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
if self.contract_version != CONNECTOR_RUNTIME_CONTRACT_VERSION:
raise ConnectorContractError(
f"Unsupported connector contract version: {self.contract_version!r}."
)
for name in ("source_ref", "source_revision", "source_fingerprint", "input_hash"):
if not str(getattr(self, name)).strip():
raise ConnectorContractError(f"Dry-run {name} cannot be blank.")
if self.summary.total != len(self.effects):
raise ConnectorContractError(
"Dry-run summary counts must match the returned effect list."
)
@property
def can_apply(self) -> bool:
return (
self.apply_token is not None
and not self.truncated
and not self.stale
and self.summary.conflicts == 0
and not any(item.severity == "error" for item in self.diagnostics)
)
def summarize_connector_effects(
effects: tuple[ConnectorEffectPreview, ...],
) -> ConnectorEffectSummary:
counts = Counter(item.effect for item in effects)
return ConnectorEffectSummary(
creates=counts["create"],
updates=counts["update"],
deletes=counts["delete"],
conflicts=counts["conflict"],
unchanged=counts["unchanged"],
ignored=counts["ignored"],
)
@runtime_checkable
class ConnectorDryRunProvider(Protocol):
def preview(
self,
session: object,
principal: object,
*,
request: ConnectorDryRunRequest,
) -> ConnectorDryRunResult:
...
__all__ = [
"CONNECTOR_RUNTIME_CONTRACT_VERSION",
"ConnectorContractError",
"ConnectorDiagnostic",
"ConnectorDiagnosticSeverity",
"ConnectorDiagnosticStage",
"ConnectorDryRunProvider",
"ConnectorDryRunRequest",
"ConnectorDryRunResult",
"ConnectorEffectKind",
"ConnectorEffectPreview",
"ConnectorEffectSummary",
"ConnectorEndpoint",
"ConnectorOutcomeState",
"summarize_connector_effects",
]
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import unittest
from datetime import UTC, datetime
from govoplan_core.core.connector_runtime import (
CONNECTOR_RUNTIME_CONTRACT_VERSION,
ConnectorContractError,
ConnectorDryRunResult,
ConnectorEffectPreview,
ConnectorEndpoint,
summarize_connector_effects,
)
class ConnectorRuntimeContractTests(unittest.TestCase):
def test_endpoint_rejects_embedded_credentials(self) -> None:
with self.assertRaisesRegex(ConnectorContractError, "must not contain credentials"):
ConnectorEndpoint(url="ldaps://user:secret@directory.example.test")
def test_dry_run_is_bounded_consistent_and_apply_gated(self) -> None:
effects = (
ConnectorEffectPreview(
effect="create",
source_object_ref="ldap:uid=one",
sample={"display_name": "Example Person"},
),
ConnectorEffectPreview(
effect="unchanged",
source_object_ref="ldap:uid=two",
target_object_ref="contact-2",
),
)
result = ConnectorDryRunResult(
contract_version=CONNECTOR_RUNTIME_CONTRACT_VERSION,
source_ref="source-1",
source_revision="revision-1",
source_fingerprint="a" * 64,
input_hash="b" * 64,
generated_at=datetime(2026, 8, 2, tzinfo=UTC),
summary=summarize_connector_effects(effects),
effects=effects,
apply_token="plan-1",
)
self.assertEqual(1, result.summary.creates)
self.assertEqual(1, result.summary.unchanged)
self.assertTrue(result.can_apply)
def test_summary_must_describe_exact_returned_effects(self) -> None:
with self.assertRaisesRegex(ConnectorContractError, "summary counts"):
ConnectorDryRunResult(
contract_version=CONNECTOR_RUNTIME_CONTRACT_VERSION,
source_ref="source-1",
source_revision="revision-1",
source_fingerprint="a" * 64,
input_hash="b" * 64,
generated_at=datetime(2026, 8, 2, tzinfo=UTC),
summary=summarize_connector_effects(()),
effects=(
ConnectorEffectPreview(
effect="create",
source_object_ref="source:one",
),
),
)
if __name__ == "__main__":
unittest.main()