From 520ffde89067bc1893a5e721ce562787ebf1f33d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Tue, 1 Sep 2026 20:08:02 +1000 Subject: [PATCH 1/8] feat(reconciliation): add raw result models --- semapact/reconciliation/models.py | 73 +++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 semapact/reconciliation/models.py diff --git a/semapact/reconciliation/models.py b/semapact/reconciliation/models.py new file mode 100644 index 0000000..8538e27 --- /dev/null +++ b/semapact/reconciliation/models.py @@ -0,0 +1,73 @@ +"""Platform-neutral reconciliation result models. + +Reconciliation reports raw desired-vs-observed differences. It does not infer +drift cause, deployment state, or governance status; those classifications are +separate downstream concerns. +""" + +from __future__ import annotations + +import json +from enum import Enum + +from pydantic import BaseModel, ConfigDict + + +class ReconciliationModel(BaseModel): + """Shared immutable base for reconciliation models.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + +class ReconciliationDifferenceType(str, Enum): + """Generic raw comparison operation, not a public reason-code taxonomy.""" + + MISSING = "missing" + UNEXPECTED = "unexpected" + MISMATCH = "mismatch" + + +class ReconciliationSubject(str, Enum): + """Comparable subject represented by a raw reconciliation difference.""" + + ASSET = "asset" + PROPERTY = "property" + PHYSICAL_TYPE = "physical_type" + NULLABILITY = "nullability" + + +class ReconciliationDifference(ReconciliationModel): + """One deterministic raw difference between desired and observed state.""" + + difference_type: ReconciliationDifferenceType + subject: ReconciliationSubject + path: str + asset_identity: str + property_identity: str | None = None + expected: str | bool | None = None + observed: str | bool | None = None + + +class ReconciliationResult(ReconciliationModel): + """Raw desired-vs-observed comparison result.""" + + contract_id: str + contract_version: str + observation_source_identifier: str + observation_fingerprint: str + differences: tuple[ReconciliationDifference, ...] = () + + @property + def has_differences(self) -> bool: + """Return whether any raw differences were found.""" + return bool(self.differences) + + +def serialize_reconciliation_result(result: ReconciliationResult) -> str: + """Serialize a reconciliation result deterministically for machine use.""" + return json.dumps( + result.model_dump(mode="json"), + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) From c9fd53a90cc7b2ef83da5d0a9816db9641490c63 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Tue, 1 Sep 2026 20:08:31 +1000 Subject: [PATCH 2/8] feat(reconciliation): compare approved and observed state --- semapact/reconciliation/engine.py | 285 ++++++++++++++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 semapact/reconciliation/engine.py diff --git a/semapact/reconciliation/engine.py b/semapact/reconciliation/engine.py new file mode 100644 index 0000000..cd6c406 --- /dev/null +++ b/semapact/reconciliation/engine.py @@ -0,0 +1,285 @@ +"""Deterministic approved-contract to observed-state reconciliation.""" + +from __future__ import annotations + +from open_data_contract_standard.model import OpenDataContractStandard, SchemaProperty + +from semapact.exceptions import ValidationError +from semapact.lifecycle.identity import ( + PropertyIdentity, + build_property_index, + build_schema_index, + normalize_identity_name, +) +from semapact.observation.fingerprint import fingerprint_observed_state +from semapact.observation.models import ObservedAsset, ObservedPlatformState, ObservedProperty +from semapact.reconciliation.models import ( + ReconciliationDifference, + ReconciliationDifferenceType, + ReconciliationResult, + ReconciliationSubject, +) + + +def reconcile_approved_contract( + contract: OpenDataContractStandard, + observation: ObservedPlatformState, +) -> ReconciliationResult: + """Compare approved ODCS desired state with platform-neutral observed state. + + This function reports raw differences only. It does not classify drift cause + or operational status and never mutates either input. + """ + approved_assets = build_schema_index(contract) + observed_assets = _build_observed_asset_index(observation) + differences: list[ReconciliationDifference] = [] + + approved_keys = set(approved_assets) + observed_keys = set(observed_assets) + + for asset_key in sorted(approved_keys - observed_keys): + differences.append( + _difference( + difference_type=ReconciliationDifferenceType.MISSING, + subject=ReconciliationSubject.ASSET, + asset_identity=asset_key, + ) + ) + + for asset_key in sorted(observed_keys - approved_keys): + differences.append( + _difference( + difference_type=ReconciliationDifferenceType.UNEXPECTED, + subject=ReconciliationSubject.ASSET, + asset_identity=asset_key, + ) + ) + + for asset_key in sorted(approved_keys & observed_keys): + approved_schema = approved_assets[asset_key] + observed_asset = observed_assets[asset_key] + differences.extend( + _reconcile_properties( + asset_key=asset_key, + approved_properties=list(approved_schema.properties or []), + observed_asset=observed_asset, + ) + ) + + ordered = tuple(sorted(differences, key=_difference_sort_key)) + return ReconciliationResult( + contract_id=_required_contract_text(getattr(contract, "id", None), field="id"), + contract_version=_required_contract_text( + getattr(contract, "version", None), field="version" + ), + observation_source_identifier=observation.source_identifier, + observation_fingerprint=( + observation.fingerprint or fingerprint_observed_state(observation) + ), + differences=ordered, + ) + + +def _reconcile_properties( + *, + asset_key: str, + approved_properties: list[SchemaProperty], + observed_asset: ObservedAsset, +) -> list[ReconciliationDifference]: + approved = build_property_index(asset_key, approved_properties) + observed = _build_observed_property_index(asset_key, observed_asset) + differences: list[ReconciliationDifference] = [] + + approved_keys = set(approved) + observed_keys = set(observed) + + for prop_key in sorted(approved_keys - observed_keys): + differences.append( + _difference( + difference_type=ReconciliationDifferenceType.MISSING, + subject=ReconciliationSubject.PROPERTY, + asset_identity=asset_key, + property_identity=prop_key[1], + ) + ) + + for prop_key in sorted(observed_keys - approved_keys): + differences.append( + _difference( + difference_type=ReconciliationDifferenceType.UNEXPECTED, + subject=ReconciliationSubject.PROPERTY, + asset_identity=asset_key, + property_identity=prop_key[1], + ) + ) + + for prop_key in sorted(approved_keys & observed_keys): + differences.extend( + _reconcile_matching_property( + asset_key=asset_key, + property_key=prop_key, + approved=approved[prop_key], + observed=observed[prop_key], + ) + ) + + return differences + + +def _reconcile_matching_property( + *, + asset_key: str, + property_key: PropertyIdentity, + approved: SchemaProperty, + observed: ObservedProperty, +) -> list[ReconciliationDifference]: + differences: list[ReconciliationDifference] = [] + property_identity = property_key[1] + + expected_physical = _optional_text(getattr(approved, "physicalType", None)) + observed_physical = _optional_text(observed.physical_type) + if ( + expected_physical is not None + and observed_physical is not None + and _normalize_comparable_text(expected_physical) + != _normalize_comparable_text(observed_physical) + ): + differences.append( + _difference( + difference_type=ReconciliationDifferenceType.MISMATCH, + subject=ReconciliationSubject.PHYSICAL_TYPE, + asset_identity=asset_key, + property_identity=property_identity, + expected=expected_physical, + observed=observed_physical, + ) + ) + + required = getattr(approved, "required", None) + nullable = observed.nullable + if isinstance(required, bool) and isinstance(nullable, bool): + expected_nullable = not required + if expected_nullable != nullable: + differences.append( + _difference( + difference_type=ReconciliationDifferenceType.MISMATCH, + subject=ReconciliationSubject.NULLABILITY, + asset_identity=asset_key, + property_identity=property_identity, + expected=expected_nullable, + observed=nullable, + ) + ) + + return differences + + +def _build_observed_asset_index( + observation: ObservedPlatformState, +) -> dict[str, ObservedAsset]: + index: dict[str, ObservedAsset] = {} + for asset in observation.assets: + key = normalize_identity_name(asset.identity.asset, "Observed asset") + if key in index: + raise ValidationError( + f"Duplicate canonical observed asset identity found: '{key}'" + ) + index[key] = asset + return index + + +def _build_observed_property_index( + asset_key: str, + asset: ObservedAsset, +) -> dict[PropertyIdentity, ObservedProperty]: + index: dict[PropertyIdentity, ObservedProperty] = {} + for prop in asset.properties: + if prop.identity.asset != asset.identity: + raise ValidationError( + "Observed property asset identity must match its containing asset" + ) + prop_name = normalize_identity_name( + prop.identity.property, "Observed property" + ) + key: PropertyIdentity = (asset_key, prop_name) + if key in index: + raise ValidationError( + f"Duplicate canonical observed property identity found: '{prop_name}'" + f" in asset '{asset_key}'" + ) + index[key] = prop + return index + + +def _difference( + *, + difference_type: ReconciliationDifferenceType, + subject: ReconciliationSubject, + asset_identity: str, + property_identity: str | None = None, + expected: str | bool | None = None, + observed: str | bool | None = None, +) -> ReconciliationDifference: + return ReconciliationDifference( + difference_type=difference_type, + subject=subject, + path=_difference_path( + subject=subject, + asset_identity=asset_identity, + property_identity=property_identity, + ), + asset_identity=asset_identity, + property_identity=property_identity, + expected=expected, + observed=observed, + ) + + +def _difference_path( + *, + subject: ReconciliationSubject, + asset_identity: str, + property_identity: str | None, +) -> str: + asset_path = f"schema[{asset_identity}]" + if subject is ReconciliationSubject.ASSET: + return asset_path + + if property_identity is None: + raise ValueError(f"property_identity is required for {subject.value}") + + property_path = f"{asset_path}.properties[{property_identity}]" + if subject is ReconciliationSubject.PROPERTY: + return property_path + if subject is ReconciliationSubject.PHYSICAL_TYPE: + return f"{property_path}.physicalType" + return f"{property_path}.nullability" + + +def _difference_sort_key( + difference: ReconciliationDifference, +) -> tuple[str, str, str, str]: + return ( + difference.asset_identity, + difference.property_identity or "", + difference.subject.value, + difference.difference_type.value, + ) + + +def _required_contract_text(value: object, *, field: str) -> str: + text = _optional_text(value) + if text is None: + raise ValidationError(f"Approved contract {field} is required for reconciliation") + return text + + +def _optional_text(value: object | None) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None + + +def _normalize_comparable_text(value: str) -> str: + return value.strip().casefold() From 422c9527394ca13614887197c954eafa5ab5e8c7 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Tue, 1 Sep 2026 20:08:50 +1000 Subject: [PATCH 3/8] feat(reconciliation): export reconciliation API --- semapact/reconciliation/__init__.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 semapact/reconciliation/__init__.py diff --git a/semapact/reconciliation/__init__.py b/semapact/reconciliation/__init__.py new file mode 100644 index 0000000..5bf3751 --- /dev/null +++ b/semapact/reconciliation/__init__.py @@ -0,0 +1,19 @@ +"""Platform-neutral desired-vs-observed reconciliation.""" + +from semapact.reconciliation.engine import reconcile_approved_contract +from semapact.reconciliation.models import ( + ReconciliationDifference, + ReconciliationDifferenceType, + ReconciliationResult, + ReconciliationSubject, + serialize_reconciliation_result, +) + +__all__ = [ + "ReconciliationDifference", + "ReconciliationDifferenceType", + "ReconciliationResult", + "ReconciliationSubject", + "reconcile_approved_contract", + "serialize_reconciliation_result", +] From a50226f692da1a066af8913320bf8ed634bc1233 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Tue, 1 Sep 2026 20:09:20 +1000 Subject: [PATCH 4/8] test(reconciliation): cover desired-observed comparison semantics --- tests/test_reconciliation.py | 300 +++++++++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 tests/test_reconciliation.py diff --git a/tests/test_reconciliation.py b/tests/test_reconciliation.py new file mode 100644 index 0000000..93e57ab --- /dev/null +++ b/tests/test_reconciliation.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +from copy import deepcopy +from datetime import datetime, timezone + +import pytest +from open_data_contract_standard.model import ( + OpenDataContractStandard, + SchemaObject, + SchemaProperty, +) + +from semapact.exceptions import ValidationError +from semapact.observation import ( + ObservedAsset, + ObservedAssetIdentity, + ObservedPlatformState, + ObservedProperty, + ObservedPropertyIdentity, + with_observed_state_fingerprint, +) +from semapact.reconciliation import ( + ReconciliationDifferenceType, + ReconciliationSubject, + reconcile_approved_contract, + serialize_reconciliation_result, +) + +CAPTURED_AT = datetime(2026, 9, 1, 10, 0, tzinfo=timezone.utc) + + +def _contract(*schemas: SchemaObject) -> OpenDataContractStandard: + return OpenDataContractStandard.model_construct( + id="orders-contract", + version="1.2.3", + schema_=list(schemas), + ) + + +def _property( + *, + asset: ObservedAssetIdentity, + name: str, + physical_type: str | None, + nullable: bool | None, +) -> ObservedProperty: + return ObservedProperty( + identity=ObservedPropertyIdentity(asset=asset, property=name), + physical_type=physical_type, + nullable=nullable, + ) + + +def _asset( + name: str, + *properties: tuple[str, str | None, bool | None], + namespace: tuple[str, ...] = ("main", "silver"), +) -> ObservedAsset: + identity = ObservedAssetIdentity( + platform="databricks", + namespace=namespace, + asset=name, + ) + return ObservedAsset( + identity=identity, + properties=tuple( + _property( + asset=identity, + name=prop_name, + physical_type=physical_type, + nullable=nullable, + ) + for prop_name, physical_type, nullable in properties + ), + ) + + +def _observation(*assets: ObservedAsset) -> ObservedPlatformState: + state = ObservedPlatformState( + platform="databricks", + source_identifier="https://adb.example", + assets=tuple(assets), + captured_at=CAPTURED_AT, + fingerprint=None, + ) + return with_observed_state_fingerprint(state) + + +def test_exact_comparable_state_has_no_differences() -> None: + contract = _contract( + SchemaObject( + name=" Orders ", + properties=[ + SchemaProperty( + name="Order_ID", + type="integer", + physicalType="BIGINT", + required=True, + ), + SchemaProperty( + name="amount", + type="number", + physicalType="DECIMAL(18,2)", + required=False, + ), + ], + ) + ) + observation = _observation( + _asset( + "orders", + ("order_id", "bigint", False), + ("amount", "decimal(18,2)", True), + namespace=("other_catalog", "other_schema"), + ) + ) + + result = reconcile_approved_contract(contract, observation) + + assert result.differences == () + assert result.has_differences is False + assert result.contract_id == "orders-contract" + assert result.contract_version == "1.2.3" + assert result.observation_fingerprint == observation.fingerprint + + +def test_missing_and_unexpected_assets_and_properties_are_reported() -> None: + contract = _contract( + SchemaObject( + name="orders", + properties=[ + SchemaProperty(name="id", type="integer", physicalType="BIGINT"), + SchemaProperty(name="amount", type="number", physicalType="DOUBLE"), + ], + ), + SchemaObject(name="customers", properties=[]), + ) + observation = _observation( + _asset( + "orders", + ("id", "bigint", None), + ("note", "string", True), + ), + _asset("payments"), + ) + + result = reconcile_approved_contract(contract, observation) + + assert [ + (item.difference_type, item.subject, item.path) + for item in result.differences + ] == [ + ( + ReconciliationDifferenceType.MISSING, + ReconciliationSubject.ASSET, + "schema[customers]", + ), + ( + ReconciliationDifferenceType.MISSING, + ReconciliationSubject.PROPERTY, + "schema[orders].properties[amount]", + ), + ( + ReconciliationDifferenceType.UNEXPECTED, + ReconciliationSubject.PROPERTY, + "schema[orders].properties[note]", + ), + ( + ReconciliationDifferenceType.UNEXPECTED, + ReconciliationSubject.ASSET, + "schema[payments]", + ), + ] + + +def test_physical_type_and_nullability_mismatches_are_reported() -> None: + contract = _contract( + SchemaObject( + name="orders", + properties=[ + SchemaProperty( + name="customer_id", + type="integer", + physicalType="BIGINT", + required=True, + ) + ], + ) + ) + observation = _observation( + _asset("orders", ("customer_id", "STRING", True)) + ) + + result = reconcile_approved_contract(contract, observation) + + assert len(result.differences) == 2 + physical, nullable = result.differences + assert physical.subject is ReconciliationSubject.PHYSICAL_TYPE + assert physical.expected == "BIGINT" + assert physical.observed == "STRING" + assert nullable.subject is ReconciliationSubject.NULLABILITY + assert nullable.expected is False + assert nullable.observed is True + + +def test_unknown_comparable_values_are_not_guessed() -> None: + contract = _contract( + SchemaObject( + name="orders", + properties=[SchemaProperty(name="id", type="integer")], + ) + ) + observation = _observation(_asset("orders", ("id", None, None))) + + result = reconcile_approved_contract(contract, observation) + + assert result.differences == () + + +def test_duplicate_canonical_observed_asset_identity_fails_closed() -> None: + contract = _contract(SchemaObject(name="orders", properties=[])) + observation = _observation( + _asset("orders", namespace=("main", "one")), + _asset(" ORDERS ", namespace=("main", "two")), + ) + + with pytest.raises( + ValidationError, + match="Duplicate canonical observed asset identity found: 'orders'", + ): + reconcile_approved_contract(contract, observation) + + +def test_duplicate_canonical_observed_property_identity_fails_closed() -> None: + contract = _contract( + SchemaObject( + name="orders", + properties=[SchemaProperty(name="id", type="integer")], + ) + ) + observation = _observation( + _asset( + "orders", + ("id", "bigint", False), + (" ID ", "bigint", False), + ) + ) + + with pytest.raises( + ValidationError, + match="Duplicate canonical observed property identity found: 'id'", + ): + reconcile_approved_contract(contract, observation) + + +def test_difference_order_and_serialization_are_deterministic() -> None: + orders = SchemaObject( + name="orders", + properties=[ + SchemaProperty(name="z_col", type="string", physicalType="STRING"), + SchemaProperty(name="a_col", type="integer", physicalType="BIGINT"), + ], + ) + users = SchemaObject(name="users", properties=[]) + contract_left = _contract(users, orders) + contract_right = _contract(orders, users) + + orders_observed = _asset( + "orders", + ("extra", "string", True), + ("a_col", "string", None), + ) + payments_observed = _asset("payments") + observation_left = _observation(payments_observed, orders_observed) + observation_right = _observation(orders_observed, payments_observed) + + left = reconcile_approved_contract(contract_left, observation_left) + right = reconcile_approved_contract(contract_right, observation_right) + + assert left.differences == right.differences + assert serialize_reconciliation_result(left) == serialize_reconciliation_result(right) + + +def test_reconciliation_does_not_mutate_inputs() -> None: + contract = _contract( + SchemaObject( + name="orders", + properties=[ + SchemaProperty(name="id", type="integer", physicalType="BIGINT") + ], + ) + ) + observation = _observation(_asset("orders", ("id", "string", False))) + contract_before = deepcopy(contract.model_dump()) + observation_before = observation.model_dump() + + reconcile_approved_contract(contract, observation) + + assert contract.model_dump() == contract_before + assert observation.model_dump() == observation_before From 7056f517e4f64a0d4b9815f27c81783bda397a18 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Tue, 1 Sep 2026 20:10:00 +1000 Subject: [PATCH 5/8] refactor(reconciliation): make difference ordering explicit --- semapact/reconciliation/engine.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/semapact/reconciliation/engine.py b/semapact/reconciliation/engine.py index cd6c406..752b67b 100644 --- a/semapact/reconciliation/engine.py +++ b/semapact/reconciliation/engine.py @@ -20,6 +20,13 @@ ReconciliationSubject, ) +_SUBJECT_ORDER = { + ReconciliationSubject.ASSET: 0, + ReconciliationSubject.PROPERTY: 1, + ReconciliationSubject.PHYSICAL_TYPE: 2, + ReconciliationSubject.NULLABILITY: 3, +} + def reconcile_approved_contract( contract: OpenDataContractStandard, @@ -258,11 +265,11 @@ def _difference_path( def _difference_sort_key( difference: ReconciliationDifference, -) -> tuple[str, str, str, str]: +) -> tuple[str, str, int, str]: return ( difference.asset_identity, difference.property_identity or "", - difference.subject.value, + _SUBJECT_ORDER[difference.subject], difference.difference_type.value, ) From 9726062e63d969424b8762323289fa40a647c3c1 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Thu, 3 Sep 2026 13:32:19 +1000 Subject: [PATCH 6/8] refactor(reconciliation): name governed desired-state boundary --- semapact/reconciliation/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/semapact/reconciliation/__init__.py b/semapact/reconciliation/__init__.py index 5bf3751..8550bf6 100644 --- a/semapact/reconciliation/__init__.py +++ b/semapact/reconciliation/__init__.py @@ -1,6 +1,6 @@ -"""Platform-neutral desired-vs-observed reconciliation.""" +"""Platform-neutral governed-desired-vs-observed reconciliation.""" -from semapact.reconciliation.engine import reconcile_approved_contract +from semapact.reconciliation.engine import reconcile_governed_contract from semapact.reconciliation.models import ( ReconciliationDifference, ReconciliationDifferenceType, @@ -14,6 +14,6 @@ "ReconciliationDifferenceType", "ReconciliationResult", "ReconciliationSubject", - "reconcile_approved_contract", + "reconcile_governed_contract", "serialize_reconciliation_result", ] From aed1c271e9d89a744ce2dc4f784403d9926067e5 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Thu, 3 Sep 2026 13:32:47 +1000 Subject: [PATCH 7/8] refactor(reconciliation): consume governed desired state --- semapact/reconciliation/engine.py | 50 +++++++++++++++++-------------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/semapact/reconciliation/engine.py b/semapact/reconciliation/engine.py index 752b67b..9dc5b2d 100644 --- a/semapact/reconciliation/engine.py +++ b/semapact/reconciliation/engine.py @@ -1,4 +1,4 @@ -"""Deterministic approved-contract to observed-state reconciliation.""" +"""Deterministic governed-desired-state to observed-state reconciliation.""" from __future__ import annotations @@ -28,23 +28,25 @@ } -def reconcile_approved_contract( +def reconcile_governed_contract( contract: OpenDataContractStandard, observation: ObservedPlatformState, ) -> ReconciliationResult: - """Compare approved ODCS desired state with platform-neutral observed state. + """Compare governed ODCS desired state with platform-neutral observed state. - This function reports raw differences only. It does not classify drift cause - or operational status and never mutates either input. + The caller is responsible for selecting the authoritative governed contract + revision. Reconciliation reports raw differences only; it does not determine + approval/authorization, classify drift cause or operational status, or mutate + either input. """ - approved_assets = build_schema_index(contract) + governed_assets = build_schema_index(contract) observed_assets = _build_observed_asset_index(observation) differences: list[ReconciliationDifference] = [] - approved_keys = set(approved_assets) + governed_keys = set(governed_assets) observed_keys = set(observed_assets) - for asset_key in sorted(approved_keys - observed_keys): + for asset_key in sorted(governed_keys - observed_keys): differences.append( _difference( difference_type=ReconciliationDifferenceType.MISSING, @@ -53,7 +55,7 @@ def reconcile_approved_contract( ) ) - for asset_key in sorted(observed_keys - approved_keys): + for asset_key in sorted(observed_keys - governed_keys): differences.append( _difference( difference_type=ReconciliationDifferenceType.UNEXPECTED, @@ -62,13 +64,13 @@ def reconcile_approved_contract( ) ) - for asset_key in sorted(approved_keys & observed_keys): - approved_schema = approved_assets[asset_key] + for asset_key in sorted(governed_keys & observed_keys): + governed_schema = governed_assets[asset_key] observed_asset = observed_assets[asset_key] differences.extend( _reconcile_properties( asset_key=asset_key, - approved_properties=list(approved_schema.properties or []), + governed_properties=list(governed_schema.properties or []), observed_asset=observed_asset, ) ) @@ -90,17 +92,17 @@ def reconcile_approved_contract( def _reconcile_properties( *, asset_key: str, - approved_properties: list[SchemaProperty], + governed_properties: list[SchemaProperty], observed_asset: ObservedAsset, ) -> list[ReconciliationDifference]: - approved = build_property_index(asset_key, approved_properties) + governed = build_property_index(asset_key, governed_properties) observed = _build_observed_property_index(asset_key, observed_asset) differences: list[ReconciliationDifference] = [] - approved_keys = set(approved) + governed_keys = set(governed) observed_keys = set(observed) - for prop_key in sorted(approved_keys - observed_keys): + for prop_key in sorted(governed_keys - observed_keys): differences.append( _difference( difference_type=ReconciliationDifferenceType.MISSING, @@ -110,7 +112,7 @@ def _reconcile_properties( ) ) - for prop_key in sorted(observed_keys - approved_keys): + for prop_key in sorted(observed_keys - governed_keys): differences.append( _difference( difference_type=ReconciliationDifferenceType.UNEXPECTED, @@ -120,12 +122,12 @@ def _reconcile_properties( ) ) - for prop_key in sorted(approved_keys & observed_keys): + for prop_key in sorted(governed_keys & observed_keys): differences.extend( _reconcile_matching_property( asset_key=asset_key, property_key=prop_key, - approved=approved[prop_key], + governed=governed[prop_key], observed=observed[prop_key], ) ) @@ -137,13 +139,13 @@ def _reconcile_matching_property( *, asset_key: str, property_key: PropertyIdentity, - approved: SchemaProperty, + governed: SchemaProperty, observed: ObservedProperty, ) -> list[ReconciliationDifference]: differences: list[ReconciliationDifference] = [] property_identity = property_key[1] - expected_physical = _optional_text(getattr(approved, "physicalType", None)) + expected_physical = _optional_text(getattr(governed, "physicalType", None)) observed_physical = _optional_text(observed.physical_type) if ( expected_physical is not None @@ -162,7 +164,7 @@ def _reconcile_matching_property( ) ) - required = getattr(approved, "required", None) + required = getattr(governed, "required", None) nullable = observed.nullable if isinstance(required, bool) and isinstance(nullable, bool): expected_nullable = not required @@ -277,7 +279,9 @@ def _difference_sort_key( def _required_contract_text(value: object, *, field: str) -> str: text = _optional_text(value) if text is None: - raise ValidationError(f"Approved contract {field} is required for reconciliation") + raise ValidationError( + f"Governed desired-state contract {field} is required for reconciliation" + ) return text From d0f21795276899563f9c90079610e8da989ed780 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Thu, 3 Sep 2026 13:33:13 +1000 Subject: [PATCH 8/8] test(reconciliation): use governed desired-state terminology --- tests/test_reconciliation.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/test_reconciliation.py b/tests/test_reconciliation.py index 93e57ab..7b57069 100644 --- a/tests/test_reconciliation.py +++ b/tests/test_reconciliation.py @@ -22,7 +22,7 @@ from semapact.reconciliation import ( ReconciliationDifferenceType, ReconciliationSubject, - reconcile_approved_contract, + reconcile_governed_contract, serialize_reconciliation_result, ) @@ -115,7 +115,7 @@ def test_exact_comparable_state_has_no_differences() -> None: ) ) - result = reconcile_approved_contract(contract, observation) + result = reconcile_governed_contract(contract, observation) assert result.differences == () assert result.has_differences is False @@ -144,7 +144,7 @@ def test_missing_and_unexpected_assets_and_properties_are_reported() -> None: _asset("payments"), ) - result = reconcile_approved_contract(contract, observation) + result = reconcile_governed_contract(contract, observation) assert [ (item.difference_type, item.subject, item.path) @@ -191,7 +191,7 @@ def test_physical_type_and_nullability_mismatches_are_reported() -> None: _asset("orders", ("customer_id", "STRING", True)) ) - result = reconcile_approved_contract(contract, observation) + result = reconcile_governed_contract(contract, observation) assert len(result.differences) == 2 physical, nullable = result.differences @@ -212,7 +212,7 @@ def test_unknown_comparable_values_are_not_guessed() -> None: ) observation = _observation(_asset("orders", ("id", None, None))) - result = reconcile_approved_contract(contract, observation) + result = reconcile_governed_contract(contract, observation) assert result.differences == () @@ -228,7 +228,7 @@ def test_duplicate_canonical_observed_asset_identity_fails_closed() -> None: ValidationError, match="Duplicate canonical observed asset identity found: 'orders'", ): - reconcile_approved_contract(contract, observation) + reconcile_governed_contract(contract, observation) def test_duplicate_canonical_observed_property_identity_fails_closed() -> None: @@ -250,7 +250,7 @@ def test_duplicate_canonical_observed_property_identity_fails_closed() -> None: ValidationError, match="Duplicate canonical observed property identity found: 'id'", ): - reconcile_approved_contract(contract, observation) + reconcile_governed_contract(contract, observation) def test_difference_order_and_serialization_are_deterministic() -> None: @@ -274,8 +274,8 @@ def test_difference_order_and_serialization_are_deterministic() -> None: observation_left = _observation(payments_observed, orders_observed) observation_right = _observation(orders_observed, payments_observed) - left = reconcile_approved_contract(contract_left, observation_left) - right = reconcile_approved_contract(contract_right, observation_right) + left = reconcile_governed_contract(contract_left, observation_left) + right = reconcile_governed_contract(contract_right, observation_right) assert left.differences == right.differences assert serialize_reconciliation_result(left) == serialize_reconciliation_result(right) @@ -294,7 +294,7 @@ def test_reconciliation_does_not_mutate_inputs() -> None: contract_before = deepcopy(contract.model_dump()) observation_before = observation.model_dump() - reconcile_approved_contract(contract, observation) + reconcile_governed_contract(contract, observation) assert contract.model_dump() == contract_before assert observation.model_dump() == observation_before